billing docs service #348
46
api/billing/documents/.air.toml
Normal file
46
api/billing/documents/.air.toml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
entrypoint = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
4
api/billing/documents/.gitignore
vendored
Normal file
4
api/billing/documents/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
internal/generated
|
||||||
|
.gocache
|
||||||
|
/app
|
||||||
|
tmp
|
||||||
50
api/billing/documents/config.dev.yml
Normal file
50
api/billing/documents/config.dev.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50061"
|
||||||
|
advertise_host: "dev-billing-documents"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9409"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: DOCUMENTS_MONGO_HOST
|
||||||
|
port_env: DOCUMENTS_MONGO_PORT
|
||||||
|
database_env: DOCUMENTS_MONGO_DATABASE
|
||||||
|
user_env: DOCUMENTS_MONGO_USER
|
||||||
|
password_env: DOCUMENTS_MONGO_PASSWORD
|
||||||
|
auth_source_env: DOCUMENTS_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: DOCUMENTS_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
documents:
|
||||||
|
issuer:
|
||||||
|
legal_name: "Sendico Ltd"
|
||||||
|
legal_address: "12 Market Street, London, UK"
|
||||||
|
logo_path: "/assets/logo.png"
|
||||||
|
templates:
|
||||||
|
acceptance_path: "templates/acceptance.tpl"
|
||||||
|
protection:
|
||||||
|
owner_password: "sendico-documents"
|
||||||
|
storage:
|
||||||
|
driver: local_fs
|
||||||
|
local:
|
||||||
|
root_path: "tmp/documents"
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Billing Documents Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
56
api/billing/documents/config.yml
Normal file
56
api/billing/documents/config.yml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
runtime:
|
||||||
|
shutdown_timeout_seconds: 15
|
||||||
|
|
||||||
|
grpc:
|
||||||
|
network: tcp
|
||||||
|
address: ":50061"
|
||||||
|
advertise_host: "sendico_billing_documents"
|
||||||
|
enable_reflection: true
|
||||||
|
enable_health: true
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
address: ":9409"
|
||||||
|
|
||||||
|
database:
|
||||||
|
driver: mongodb
|
||||||
|
settings:
|
||||||
|
host_env: DOCUMENTS_MONGO_HOST
|
||||||
|
port_env: DOCUMENTS_MONGO_PORT
|
||||||
|
database_env: DOCUMENTS_MONGO_DATABASE
|
||||||
|
user_env: DOCUMENTS_MONGO_USER
|
||||||
|
password_env: DOCUMENTS_MONGO_PASSWORD
|
||||||
|
auth_source_env: DOCUMENTS_MONGO_AUTH_SOURCE
|
||||||
|
replica_set_env: DOCUMENTS_MONGO_REPLICA_SET
|
||||||
|
|
||||||
|
documents:
|
||||||
|
issuer:
|
||||||
|
legal_name: "Sendico Ltd"
|
||||||
|
legal_address: "12 Market Street, London, UK"
|
||||||
|
logo_path: "/assets/logo.png"
|
||||||
|
templates:
|
||||||
|
acceptance_path: "templates/acceptance.tpl"
|
||||||
|
protection:
|
||||||
|
owner_password: "sendico-documents"
|
||||||
|
storage:
|
||||||
|
driver: minio
|
||||||
|
s3:
|
||||||
|
endpoint: "s3.sendico.io"
|
||||||
|
region: "us-east-1"
|
||||||
|
bucket: "sendico-documents"
|
||||||
|
access_key_env: DOCUMENTS_S3_ACCESS_KEY
|
||||||
|
secret_access_key_env: DOCUMENTS_S3_SECRET_KEY
|
||||||
|
use_ssl: true
|
||||||
|
force_path_style: true
|
||||||
|
|
||||||
|
messaging:
|
||||||
|
driver: NATS
|
||||||
|
settings:
|
||||||
|
url_env: NATS_URL
|
||||||
|
host_env: NATS_HOST
|
||||||
|
port_env: NATS_PORT
|
||||||
|
username_env: NATS_USER
|
||||||
|
password_env: NATS_PASSWORD
|
||||||
|
broker_name: Billing Documents Service
|
||||||
|
max_reconnects: 10
|
||||||
|
reconnect_wait: 5
|
||||||
|
buffer_size: 1024
|
||||||
72
api/billing/documents/go.mod
Normal file
72
api/billing/documents/go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
275
api/billing/documents/go.sum
Normal file
275
api/billing/documents/go.sum
Normal file
@@ -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=
|
||||||
28
api/billing/documents/internal/appversion/version.go
Normal file
28
api/billing/documents/internal/appversion/version.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package appversion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/pkg/version"
|
||||||
|
vf "github.com/tech/sendico/pkg/version/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build information populated at build time.
|
||||||
|
var (
|
||||||
|
Version string
|
||||||
|
Revision string
|
||||||
|
Branch string
|
||||||
|
BuildUser string
|
||||||
|
BuildDate string
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create initialises a version.Printer with the build details for this service.
|
||||||
|
func Create() version.Printer {
|
||||||
|
info := version.Info{
|
||||||
|
Program: "Sendico Billing Documents Service",
|
||||||
|
Revision: Revision,
|
||||||
|
Branch: Branch,
|
||||||
|
BuildUser: BuildUser,
|
||||||
|
BuildDate: BuildDate,
|
||||||
|
Version: Version,
|
||||||
|
}
|
||||||
|
return vf.Create(&info)
|
||||||
|
}
|
||||||
61
api/billing/documents/internal/docstore/local.go
Normal file
61
api/billing/documents/internal/docstore/local.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package docstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LocalStore struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
rootPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error) {
|
||||||
|
root := strings.TrimSpace(cfg.RootPath)
|
||||||
|
if root == "" {
|
||||||
|
return nil, merrors.InvalidArgument("docstore: local root_path is empty")
|
||||||
|
}
|
||||||
|
store := &LocalStore{
|
||||||
|
logger: logger.Named("docstore").Named("local"),
|
||||||
|
rootPath: root,
|
||||||
|
}
|
||||||
|
store.logger.Info("Document storage initialised", zap.String("root_path", root))
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalStore) Save(ctx context.Context, key string, data []byte) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
s.logger.Warn("Failed to create document directory", zap.Error(err), zap.String("path", path))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||||
|
s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Store = (*LocalStore)(nil)
|
||||||
125
api/billing/documents/internal/docstore/s3.go
Normal file
125
api/billing/documents/internal/docstore/s3.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package docstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type S3Store struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
client *s3.Client
|
||||||
|
bucket string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||||
|
bucket := strings.TrimSpace(cfg.Bucket)
|
||||||
|
if bucket == "" {
|
||||||
|
return nil, merrors.InvalidArgument("docstore: bucket is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKey := strings.TrimSpace(cfg.AccessKey)
|
||||||
|
if accessKey == "" && cfg.AccessKeyEnv != "" {
|
||||||
|
accessKey = strings.TrimSpace(os.Getenv(cfg.AccessKeyEnv))
|
||||||
|
}
|
||||||
|
secretKey := strings.TrimSpace(cfg.SecretAccessKey)
|
||||||
|
if secretKey == "" && cfg.SecretKeyEnv != "" {
|
||||||
|
secretKey = strings.TrimSpace(os.Getenv(cfg.SecretKeyEnv))
|
||||||
|
}
|
||||||
|
|
||||||
|
region := strings.TrimSpace(cfg.Region)
|
||||||
|
if region == "" {
|
||||||
|
region = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOpts := []func(*config.LoadOptions) error{
|
||||||
|
config.WithRegion(region),
|
||||||
|
}
|
||||||
|
if accessKey != "" || secretKey != "" {
|
||||||
|
loadOpts = append(loadOpts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
"",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||||
|
if endpoint != "" {
|
||||||
|
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||||||
|
if cfg.UseSSL {
|
||||||
|
endpoint = "https://" + endpoint
|
||||||
|
} else {
|
||||||
|
endpoint = "http://" + endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||||
|
if service == s3.ServiceID {
|
||||||
|
return aws.Endpoint{URL: endpoint, SigningRegion: region, HostnameImmutable: true}, nil
|
||||||
|
}
|
||||||
|
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
|
||||||
|
})
|
||||||
|
loadOpts = append(loadOpts, config.WithEndpointResolverWithOptions(resolver))
|
||||||
|
}
|
||||||
|
|
||||||
|
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
|
||||||
|
opts.UsePathStyle = cfg.ForcePathStyle
|
||||||
|
})
|
||||||
|
|
||||||
|
store := &S3Store{
|
||||||
|
logger: logger.Named("docstore").Named("s3"),
|
||||||
|
client: client,
|
||||||
|
bucket: bucket,
|
||||||
|
}
|
||||||
|
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: bytes.NewReader(data),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) Load(ctx context.Context, key string) ([]byte, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to fetch document", zap.Error(err), zap.String("key", key))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer obj.Body.Close()
|
||||||
|
return io.ReadAll(obj.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Store = (*S3Store)(nil)
|
||||||
67
api/billing/documents/internal/docstore/store.go
Normal file
67
api/billing/documents/internal/docstore/store.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package docstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Driver identifies the document storage backend.
|
||||||
|
type Driver string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DriverLocal Driver = "local_fs"
|
||||||
|
DriverS3 Driver = "s3"
|
||||||
|
DriverMinio Driver = "minio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the document storage backend.
|
||||||
|
type Config struct {
|
||||||
|
Driver Driver `yaml:"driver"`
|
||||||
|
Local *LocalConfig `yaml:"local"`
|
||||||
|
S3 *S3Config `yaml:"s3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalConfig configures local filesystem storage.
|
||||||
|
type LocalConfig struct {
|
||||||
|
RootPath string `yaml:"root_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Config configures S3/Minio storage.
|
||||||
|
type S3Config struct {
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
Bucket string `yaml:"bucket"`
|
||||||
|
AccessKeyEnv string `yaml:"access_key_env"`
|
||||||
|
SecretKeyEnv string `yaml:"secret_access_key_env"`
|
||||||
|
AccessKey string `yaml:"access_key"`
|
||||||
|
SecretAccessKey string `yaml:"secret_access_key"`
|
||||||
|
UseSSL bool `yaml:"use_ssl"`
|
||||||
|
ForcePathStyle bool `yaml:"force_path_style"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store defines storage operations for generated documents.
|
||||||
|
type Store interface {
|
||||||
|
Save(ctx context.Context, key string, data []byte) error
|
||||||
|
Load(ctx context.Context, key string) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a document store based on config.
|
||||||
|
func New(logger mlogger.Logger, cfg Config) (Store, error) {
|
||||||
|
switch strings.ToLower(string(cfg.Driver)) {
|
||||||
|
case string(DriverLocal):
|
||||||
|
if cfg.Local == nil {
|
||||||
|
return nil, merrors.InvalidArgument("docstore: local config missing")
|
||||||
|
}
|
||||||
|
return NewLocalStore(logger, *cfg.Local)
|
||||||
|
case string(DriverS3), string(DriverMinio):
|
||||||
|
if cfg.S3 == nil {
|
||||||
|
return nil, merrors.InvalidArgument("docstore: s3 config missing")
|
||||||
|
}
|
||||||
|
return NewS3Store(logger, *cfg.S3)
|
||||||
|
default:
|
||||||
|
return nil, merrors.InvalidArgument("docstore: unsupported driver")
|
||||||
|
}
|
||||||
|
}
|
||||||
144
api/billing/documents/internal/server/internal/serverimp.go
Normal file
144
api/billing/documents/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package serverimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/service/documents"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
|
mongostorage "github.com/tech/sendico/billing/documents/storage/mongo"
|
||||||
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
msg "github.com/tech/sendico/pkg/messaging"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Imp struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
file string
|
||||||
|
debug bool
|
||||||
|
config *config
|
||||||
|
app *grpcapp.App[storage.Repository]
|
||||||
|
service *documents.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
*grpcapp.Config `yaml:",inline"`
|
||||||
|
Documents documents.Config `yaml:"documents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initialises the billing documents server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||||
|
return &Imp{
|
||||||
|
logger: logger.Named("server"),
|
||||||
|
file: file,
|
||||||
|
debug: debug,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Shutdown() {
|
||||||
|
if i.app == nil {
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if i.config != nil && i.config.Runtime != nil {
|
||||||
|
timeout = i.config.Runtime.ShutdownTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.service != nil {
|
||||||
|
i.service.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
i.app.Shutdown(ctx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) Start() error {
|
||||||
|
cfg, err := i.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.config = cfg
|
||||||
|
|
||||||
|
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||||
|
return mongostorage.New(logger, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
docStore, err := docstore.New(i.logger, cfg.Documents.Storage)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Failed to initialise document storage", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||||
|
invokeURI := ""
|
||||||
|
if cfg.GRPC != nil {
|
||||||
|
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||||
|
}
|
||||||
|
svc := documents.NewService(logger, repo, producer,
|
||||||
|
documents.WithDiscoveryInvokeURI(invokeURI),
|
||||||
|
documents.WithConfig(cfg.Documents),
|
||||||
|
documents.WithDocumentStore(docStore),
|
||||||
|
)
|
||||||
|
i.service = svc
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := grpcapp.NewApp(i.logger, "billing_documents", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i.app = app
|
||||||
|
|
||||||
|
return i.app.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Imp) loadConfig() (*config, error) {
|
||||||
|
data, err := os.ReadFile(i.file)
|
||||||
|
if err != nil {
|
||||||
|
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config{Config: &grpcapp.Config{}}
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Runtime == nil {
|
||||||
|
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.GRPC == nil {
|
||||||
|
cfg.GRPC = &routers.GRPCConfig{
|
||||||
|
Network: "tcp",
|
||||||
|
Address: ":50061",
|
||||||
|
EnableReflection: true,
|
||||||
|
EnableHealth: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics == nil {
|
||||||
|
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9409"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Documents.Storage.Driver == "" {
|
||||||
|
cfg.Documents.Storage.Driver = docstore.DriverLocal
|
||||||
|
if cfg.Documents.Storage.Local == nil {
|
||||||
|
cfg.Documents.Storage.Local = &docstore.LocalConfig{RootPath: "tmp/documents"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
12
api/billing/documents/internal/server/server.go
Normal file
12
api/billing/documents/internal/server/server.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
serverimp "github.com/tech/sendico/billing/documents/internal/server/internal"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create constructs the billing documents server implementation.
|
||||||
|
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return serverimp.Create(logger, file, debug)
|
||||||
|
}
|
||||||
33
api/billing/documents/internal/service/documents/config.go
Normal file
33
api/billing/documents/internal/service/documents/config.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
||||||
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds document service settings loaded from YAML.
|
||||||
|
type Config struct {
|
||||||
|
Issuer renderer.Issuer `yaml:"issuer"`
|
||||||
|
Templates TemplateConfig `yaml:"templates"`
|
||||||
|
Protection ProtectionConfig `yaml:"protection"`
|
||||||
|
Storage docstore.Config `yaml:"storage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateConfig defines document template locations.
|
||||||
|
type TemplateConfig struct {
|
||||||
|
AcceptancePath string `yaml:"acceptance_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtectionConfig configures PDF protection.
|
||||||
|
type ProtectionConfig struct {
|
||||||
|
OwnerPassword string `yaml:"owner_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) AcceptanceTemplatePath() string {
|
||||||
|
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
||||||
|
return "templates/acceptance.tpl"
|
||||||
|
}
|
||||||
|
return c.Templates.AcceptancePath
|
||||||
|
}
|
||||||
105
api/billing/documents/internal/service/documents/metrics.go
Normal file
105
api/billing/documents/internal/service/documents/metrics.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsOnce sync.Once
|
||||||
|
|
||||||
|
requestsTotal *prometheus.CounterVec
|
||||||
|
requestLatency *prometheus.HistogramVec
|
||||||
|
batchSize prometheus.Histogram
|
||||||
|
documentBytes *prometheus.HistogramVec
|
||||||
|
)
|
||||||
|
|
||||||
|
func initMetrics() {
|
||||||
|
metricsOnce.Do(func() {
|
||||||
|
requestsTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "requests_total",
|
||||||
|
Help: "Total number of billing document requests processed.",
|
||||||
|
},
|
||||||
|
[]string{"call", "status", "doc_type"},
|
||||||
|
)
|
||||||
|
|
||||||
|
requestLatency = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "request_latency_seconds",
|
||||||
|
Help: "Latency of billing document requests.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
},
|
||||||
|
[]string{"call", "status", "doc_type"},
|
||||||
|
)
|
||||||
|
|
||||||
|
batchSize = promauto.NewHistogram(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "batch_size",
|
||||||
|
Help: "Number of payment references in batch resolution requests.",
|
||||||
|
Buckets: []float64{0, 1, 2, 5, 10, 20, 50, 100, 250, 500},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
documentBytes = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: "billing",
|
||||||
|
Subsystem: "documents",
|
||||||
|
Name: "document_bytes",
|
||||||
|
Help: "Size of generated billing document payloads.",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(1024, 2, 10),
|
||||||
|
},
|
||||||
|
[]string{"doc_type"},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeRequest(call string, docType documentsv1.DocumentType, statusLabel string, took time.Duration) {
|
||||||
|
typeLabel := docTypeLabel(docType)
|
||||||
|
requestsTotal.WithLabelValues(call, statusLabel, typeLabel).Inc()
|
||||||
|
requestLatency.WithLabelValues(call, statusLabel, typeLabel).Observe(took.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeBatchSize(size int) {
|
||||||
|
batchSize.Observe(float64(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
func observeDocumentBytes(docType documentsv1.DocumentType, size int) {
|
||||||
|
documentBytes.WithLabelValues(docTypeLabel(docType)).Observe(float64(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusFromError(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
if !ok {
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
code := st.Code()
|
||||||
|
if code == codes.OK {
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
return strings.ToLower(code.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func docTypeLabel(docType documentsv1.DocumentType) string {
|
||||||
|
label := docType.String()
|
||||||
|
if label == "" {
|
||||||
|
return "DOCUMENT_TYPE_UNSPECIFIED"
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
433
api/billing/documents/internal/service/documents/service.go
Normal file
433
api/billing/documents/internal/service/documents/service.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
176
api/billing/documents/internal/service/documents/service_test.go
Normal file
176
api/billing/documents/internal/service/documents/service_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
60
api/billing/documents/internal/service/documents/template.go
Normal file
60
api/billing/documents/internal/service/documents/template.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package documents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/billing/documents/renderer"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type templateRenderer struct {
|
||||||
|
tpl *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"money": formatMoney,
|
||||||
|
"date": formatDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl, err := template.New("acceptance").Funcs(funcs).Option("missingkey=error").Parse(string(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &templateRenderer{tpl: tpl}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := r.tpl.Execute(&buf, snapshot); err != nil {
|
||||||
|
return nil, fmt.Errorf("execute template: %w", err)
|
||||||
|
}
|
||||||
|
return renderer.ParseBlocks(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMoney(amount decimal.Decimal, currency string) string {
|
||||||
|
currency = strings.TrimSpace(currency)
|
||||||
|
if currency == "" {
|
||||||
|
return amount.String()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s", amount.String(), currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDate(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
17
api/billing/documents/main.go
Normal file
17
api/billing/documents/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/billing/documents/internal/appversion"
|
||||||
|
si "github.com/tech/sendico/billing/documents/internal/server"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/server"
|
||||||
|
smain "github.com/tech/sendico/pkg/server/main"
|
||||||
|
)
|
||||||
|
|
||||||
|
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||||
|
return si.Create(logger, file, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
smain.RunServer("main", appversion.Create(), factory)
|
||||||
|
}
|
||||||
50
api/billing/documents/renderer/header.go
Normal file
50
api/billing/documents/renderer/header.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issuer describes the document issuer.
|
||||||
|
type Issuer struct {
|
||||||
|
LegalName string `yaml:"legal_name"`
|
||||||
|
LegalAddress string `yaml:"legal_address"`
|
||||||
|
LogoPath string `yaml:"logo_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64) (float64, error) {
|
||||||
|
startX := marginLeft
|
||||||
|
startY := marginTop
|
||||||
|
logoWidth := 0.0
|
||||||
|
|
||||||
|
if strings.TrimSpace(issuer.LogoPath) != "" {
|
||||||
|
logoWidth = 24
|
||||||
|
pdf.ImageOptions(issuer.LogoPath, startX, startY, logoWidth, 0, false, gofpdf.ImageOptions{ReadDpi: true}, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
textX := startX
|
||||||
|
if logoWidth > 0 {
|
||||||
|
textX = startX + logoWidth + 6
|
||||||
|
}
|
||||||
|
pdf.SetXY(textX, startY)
|
||||||
|
pdf.SetFont("Helvetica", "B", 12)
|
||||||
|
pdf.CellFormat(0, 5, issuer.LegalName, "", 1, "L", false, 0, "")
|
||||||
|
pdf.SetX(textX)
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
pdf.MultiCell(0, 4.5, issuer.LegalAddress, "", "L", false)
|
||||||
|
|
||||||
|
if pdf.Error() != nil {
|
||||||
|
return 0, pdf.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY := pdf.GetY()
|
||||||
|
if logoWidth > 0 {
|
||||||
|
logoBottom := startY + logoWidth
|
||||||
|
if logoBottom > currentY {
|
||||||
|
currentY = logoBottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentY - startY, nil
|
||||||
|
}
|
||||||
221
api/billing/documents/renderer/layout.go
Normal file
221
api/billing/documents/renderer/layout.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pageMarginLeft = 20.0
|
||||||
|
pageMarginRight = 20.0
|
||||||
|
pageMarginTop = 20.0
|
||||||
|
pageMarginBottom = 22.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Renderer builds a PDF document from tagged blocks.
|
||||||
|
type Renderer struct {
|
||||||
|
Issuer Issuer
|
||||||
|
OwnerPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render generates the PDF bytes for the provided blocks and footer hash.
|
||||||
|
func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.SetMargins(pageMarginLeft, pageMarginTop, pageMarginRight)
|
||||||
|
pdf.SetAutoPageBreak(true, pageMarginBottom)
|
||||||
|
pdf.SetCompression(false)
|
||||||
|
pdf.SetAuthor(r.Issuer.LegalName, false)
|
||||||
|
pdf.SetTitle("Act of Acceptance", false)
|
||||||
|
|
||||||
|
owner := strings.TrimSpace(r.OwnerPassword)
|
||||||
|
if owner != "" {
|
||||||
|
pdf.SetProtection(gofpdf.CnProtectPrint, "", owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetFooterFunc(func() {
|
||||||
|
pdf.SetY(-15)
|
||||||
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
|
footer := fmt.Sprintf("Document integrity hash: %s", footerHash)
|
||||||
|
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
pdf.AddPage()
|
||||||
|
if _, err := drawHeader(pdf, r.Issuer, pageMarginLeft, pageMarginTop); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pdf.Ln(6)
|
||||||
|
|
||||||
|
for _, block := range blocks {
|
||||||
|
renderBlock(pdf, block)
|
||||||
|
if pdf.Error() != nil {
|
||||||
|
return nil, pdf.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
if err := pdf.Output(buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||||
|
switch block.Tag {
|
||||||
|
case TagSpacer:
|
||||||
|
pdf.Ln(6)
|
||||||
|
case TagTitle:
|
||||||
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
|
for _, line := range block.Lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
pdf.Ln(4)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pdf.CellFormat(0, 7, line, "", 1, "C", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(2)
|
||||||
|
case TagSubtitle:
|
||||||
|
pdf.SetFont("Helvetica", "", 11)
|
||||||
|
for _, line := range block.Lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
pdf.Ln(3)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pdf.CellFormat(0, 6, line, "", 1, "C", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(2)
|
||||||
|
case TagMeta:
|
||||||
|
pdf.SetFont("Helvetica", "", 9)
|
||||||
|
for _, line := range block.Lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
pdf.Ln(2)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pdf.CellFormat(0, 4.5, line, "", 1, "R", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(2)
|
||||||
|
case TagSection:
|
||||||
|
pdf.Ln(2)
|
||||||
|
pdf.SetFont("Helvetica", "B", 11)
|
||||||
|
for _, line := range block.Lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
pdf.Ln(3)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pdf.CellFormat(0, 6, line, "", 1, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(1)
|
||||||
|
case TagText:
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
text := strings.Join(block.Lines, "\n")
|
||||||
|
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||||
|
pdf.Ln(1)
|
||||||
|
case TagKV:
|
||||||
|
renderKeyValue(pdf, block)
|
||||||
|
case TagTable:
|
||||||
|
renderTable(pdf, block)
|
||||||
|
case TagSign:
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
text := strings.Join(block.Lines, "\n")
|
||||||
|
pdf.MultiCell(0, 6, text, "", "L", false)
|
||||||
|
pdf.Ln(2)
|
||||||
|
default:
|
||||||
|
// Unknown tag: treat as plain text for resilience.
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
text := strings.Join(block.Lines, "\n")
|
||||||
|
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||||
|
pdf.Ln(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
usable := usableWidth(pdf)
|
||||||
|
keyWidth := math.Round(usable * 0.35)
|
||||||
|
valueWidth := usable - keyWidth
|
||||||
|
lineHeight := 5.0
|
||||||
|
|
||||||
|
for _, row := range block.Rows {
|
||||||
|
if len(row) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := row[0]
|
||||||
|
value := ""
|
||||||
|
if len(row) > 1 {
|
||||||
|
value = row[1]
|
||||||
|
}
|
||||||
|
x := pdf.GetX()
|
||||||
|
y := pdf.GetY()
|
||||||
|
|
||||||
|
pdf.SetXY(x, y)
|
||||||
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
|
pdf.MultiCell(keyWidth, lineHeight, key, "", "L", false)
|
||||||
|
leftY := pdf.GetY()
|
||||||
|
|
||||||
|
pdf.SetXY(x+keyWidth, y)
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
pdf.MultiCell(valueWidth, lineHeight, value, "", "L", false)
|
||||||
|
rightY := pdf.GetY()
|
||||||
|
|
||||||
|
pdf.SetY(maxFloat(leftY, rightY))
|
||||||
|
}
|
||||||
|
pdf.Ln(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||||
|
if len(block.Rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usable := usableWidth(pdf)
|
||||||
|
col1 := math.Round(usable * 0.7)
|
||||||
|
col2 := usable - col1
|
||||||
|
lineHeight := 6.0
|
||||||
|
|
||||||
|
header := block.Rows[0]
|
||||||
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
|
if len(header) > 0 {
|
||||||
|
pdf.CellFormat(col1, lineHeight, header[0], "1", 0, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
if len(header) > 1 {
|
||||||
|
pdf.CellFormat(col2, lineHeight, header[1], "1", 1, "R", false, 0, "")
|
||||||
|
} else {
|
||||||
|
pdf.CellFormat(col2, lineHeight, "", "1", 1, "R", false, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
for _, row := range block.Rows[1:] {
|
||||||
|
colA := ""
|
||||||
|
colB := ""
|
||||||
|
if len(row) > 0 {
|
||||||
|
colA = row[0]
|
||||||
|
}
|
||||||
|
if len(row) > 1 {
|
||||||
|
colB = row[1]
|
||||||
|
}
|
||||||
|
x := pdf.GetX()
|
||||||
|
y := pdf.GetY()
|
||||||
|
pdf.MultiCell(col1, lineHeight, colA, "1", "L", false)
|
||||||
|
leftY := pdf.GetY()
|
||||||
|
pdf.SetXY(x+col1, y)
|
||||||
|
pdf.MultiCell(col2, lineHeight, colB, "1", "R", false)
|
||||||
|
rightY := pdf.GetY()
|
||||||
|
pdf.SetY(maxFloat(leftY, rightY))
|
||||||
|
}
|
||||||
|
pdf.Ln(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usableWidth(pdf *gofpdf.Fpdf) float64 {
|
||||||
|
pageW, _ := pdf.GetPageSize()
|
||||||
|
left, _, right, _ := pdf.GetMargins()
|
||||||
|
return pageW - left - right
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxFloat(a, b float64) float64 {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
90
api/billing/documents/renderer/renderer_test.go
Normal file
90
api/billing/documents/renderer/renderer_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderer_RenderContainsText(t *testing.T) {
|
||||||
|
blocks := []Block{
|
||||||
|
{Tag: TagTitle, Lines: []string{"ACT"}},
|
||||||
|
{Tag: TagText, Lines: []string{"Executor: Jane Doe", "Amount: 100 USD"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := Renderer{
|
||||||
|
Issuer: Issuer{
|
||||||
|
LegalName: "Sendico Ltd",
|
||||||
|
LegalAddress: "12 Market Street, London, UK",
|
||||||
|
},
|
||||||
|
OwnerPassword: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfBytes, err := r.Render(blocks, "deadbeef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
if len(pdfBytes) == 0 {
|
||||||
|
t.Fatalf("expected PDF bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
|
||||||
|
for _, token := range checks {
|
||||||
|
if !containsPDFText(pdfBytes, token) {
|
||||||
|
t.Fatalf("expected PDF to contain %q", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPDFText(pdfBytes []byte, text string) bool {
|
||||||
|
if bytes.Contains(pdfBytes, []byte(text)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
hexText := hex.EncodeToString([]byte(text))
|
||||||
|
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(hexText))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if bytes.Contains(pdfBytes, []byte(strings.ToLower(hexText))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
utf16Bytes := encodeUTF16BE(text, false)
|
||||||
|
if bytes.Contains(pdfBytes, utf16Bytes) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
utf16Hex := hex.EncodeToString(utf16Bytes)
|
||||||
|
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16Hex))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16Hex))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
utf16BytesBOM := encodeUTF16BE(text, true)
|
||||||
|
if bytes.Contains(pdfBytes, utf16BytesBOM) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
utf16HexBOM := hex.EncodeToString(utf16BytesBOM)
|
||||||
|
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16HexBOM))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16HexBOM)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeUTF16BE(text string, withBOM bool) []byte {
|
||||||
|
encoded := utf16.Encode([]rune(text))
|
||||||
|
length := len(encoded) * 2
|
||||||
|
if withBOM {
|
||||||
|
length += 2
|
||||||
|
}
|
||||||
|
out := make([]byte, 0, length)
|
||||||
|
if withBOM {
|
||||||
|
out = append(out, 0xFE, 0xFF)
|
||||||
|
}
|
||||||
|
for _, v := range encoded {
|
||||||
|
out = append(out, byte(v>>8), byte(v))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
87
api/billing/documents/renderer/tags.go
Normal file
87
api/billing/documents/renderer/tags.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package renderer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag defines supported template blocks.
|
||||||
|
type Tag string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TagSpacer Tag = "spacer"
|
||||||
|
TagTitle Tag = "title"
|
||||||
|
TagSubtitle Tag = "subtitle"
|
||||||
|
TagMeta Tag = "meta"
|
||||||
|
TagSection Tag = "section"
|
||||||
|
TagText Tag = "text"
|
||||||
|
TagKV Tag = "kv"
|
||||||
|
TagTable Tag = "table"
|
||||||
|
TagSign Tag = "sign"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block represents a tagged content block extracted from template output.
|
||||||
|
type Block struct {
|
||||||
|
Tag Tag
|
||||||
|
Lines []string
|
||||||
|
Rows [][]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBlocks converts tagged template output into structured blocks.
|
||||||
|
func ParseBlocks(input string) ([]Block, error) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||||
|
blocks := make([]Block, 0)
|
||||||
|
var current *Block
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if current != nil {
|
||||||
|
blocks = append(blocks, *current)
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimRight(scanner.Text(), "\r")
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
flush()
|
||||||
|
tag := Tag(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tag == TagSpacer {
|
||||||
|
blocks = append(blocks, Block{Tag: TagSpacer})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = &Block{Tag: tag}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch current.Tag {
|
||||||
|
case TagKV, TagTable:
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
row := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
row = append(row, strings.TrimSpace(part))
|
||||||
|
}
|
||||||
|
current.Rows = append(current.Rows, row)
|
||||||
|
default:
|
||||||
|
current.Lines = append(current.Lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse blocks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return blocks, nil
|
||||||
|
}
|
||||||
92
api/billing/documents/storage/model/document.go
Normal file
92
api/billing/documents/storage/model/document.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
68
api/billing/documents/storage/mongo/repository.go
Normal file
68
api/billing/documents/storage/mongo/repository.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package mongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/storage"
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/mongo/store"
|
||||||
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/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)
|
||||||
145
api/billing/documents/storage/mongo/store/documents.go
Normal file
145
api/billing/documents/storage/mongo/store/documents.go
Normal file
@@ -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)
|
||||||
32
api/billing/documents/storage/storage.go
Normal file
32
api/billing/documents/storage/storage.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/documents/storage/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type storageError string
|
||||||
|
|
||||||
|
func (e storageError) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDocumentNotFound = storageError("billing.documents.storage: document record not found")
|
||||||
|
ErrDuplicateDocument = storageError("billing.documents.storage: duplicate document record")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository defines the root storage contract for the billing documents service.
|
||||||
|
type Repository interface {
|
||||||
|
Ping(ctx context.Context) error
|
||||||
|
Documents() DocumentsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentsStore exposes persistence operations for document records.
|
||||||
|
type DocumentsStore interface {
|
||||||
|
Create(ctx context.Context, record *model.DocumentRecord) error
|
||||||
|
Update(ctx context.Context, record *model.DocumentRecord) error
|
||||||
|
GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error)
|
||||||
|
ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error)
|
||||||
|
}
|
||||||
70
api/billing/documents/templates/acceptance.tpl
Normal file
70
api/billing/documents/templates/acceptance.tpl
Normal file
@@ -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 ___________________________
|
||||||
@@ -32,7 +32,7 @@ require (
|
|||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // 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/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -49,6 +49,6 @@ require (
|
|||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.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
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.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.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||||
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/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
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/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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ require (
|
|||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // 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/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // 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/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.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/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.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.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||||
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/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
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/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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.48.0 // 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/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // 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/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.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/grpc v1.78.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.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.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||||
github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM=
|
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1:xXzuihhT3gL/ntduUZwHECzAn57E8dA6l8SOtYWdD8Q=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||||
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/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
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/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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
Reference in New Issue
Block a user