Fixes + stable gateway ids
This commit is contained in:
@@ -24,5 +24,6 @@ func Create() version.Printer {
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return vf.Create(&info)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error)
|
||||
if root == "" {
|
||||
return nil, merrors.InvalidArgument("docstore: local root_path is empty")
|
||||
}
|
||||
|
||||
store := &LocalStore{
|
||||
logger: logger.Named("docstore").Named("local"),
|
||||
rootPath: root,
|
||||
}
|
||||
store.logger.Info("Document storage initialised", zap.String("root_path", root))
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
@@ -33,15 +35,19 @@ func (s *LocalStore) Save(ctx context.Context, key string, data []byte) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
s.logger.Warn("Failed to create document directory", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,12 +55,16 @@ func (s *LocalStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
if accessKey == "" && cfg.AccessKeyEnv != "" {
|
||||
accessKey = strings.TrimSpace(os.Getenv(cfg.AccessKeyEnv))
|
||||
}
|
||||
|
||||
secretKey := strings.TrimSpace(cfg.SecretAccessKey)
|
||||
if secretKey == "" && cfg.SecretKeyEnv != "" {
|
||||
secretKey = strings.TrimSpace(os.Getenv(cfg.SecretKeyEnv))
|
||||
@@ -62,23 +63,21 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
endpoint = "http://" + endpoint
|
||||
}
|
||||
}
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||
if service == s3.ServiceID {
|
||||
return aws.Endpoint{URL: endpoint, SigningRegion: region, HostnameImmutable: true}, nil
|
||||
}
|
||||
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
|
||||
})
|
||||
loadOpts = append(loadOpts, config.WithEndpointResolverWithOptions(resolver))
|
||||
}
|
||||
|
||||
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
|
||||
opts.UsePathStyle = cfg.ForcePathStyle
|
||||
|
||||
if endpoint != "" {
|
||||
opts.BaseEndpoint = aws.String(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
store := &S3Store{
|
||||
@@ -87,6 +86,7 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
bucket: bucket,
|
||||
}
|
||||
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
@@ -101,8 +102,10 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,15 +113,19 @@ func (s *S3Store) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch document", zap.Error(err), zap.String("key", key))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer obj.Body.Close()
|
||||
|
||||
return io.ReadAll(obj.Body)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ type S3Config struct {
|
||||
Bucket string `yaml:"bucket"`
|
||||
AccessKeyEnv string `yaml:"access_key_env"`
|
||||
SecretKeyEnv string `yaml:"secret_access_key_env"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
AccessKey string `yaml:"access_key"` //nolint:gosec // config field, not a hardcoded secret
|
||||
SecretAccessKey string `yaml:"secret_access_key"`
|
||||
UseSSL bool `yaml:"use_ssl"`
|
||||
ForcePathStyle bool `yaml:"force_path_style"`
|
||||
@@ -55,11 +55,13 @@ func New(logger mlogger.Logger, cfg Config) (Store, error) {
|
||||
if cfg.Local == nil {
|
||||
return nil, merrors.InvalidArgument("docstore: local config missing")
|
||||
}
|
||||
|
||||
return NewLocalStore(logger, *cfg.Local)
|
||||
case string(DriverS3), string(DriverMinio):
|
||||
if cfg.S3 == nil {
|
||||
return nil, merrors.InvalidArgument("docstore: s3 config missing")
|
||||
}
|
||||
|
||||
return NewS3Store(logger, *cfg.S3)
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("docstore: unsupported driver")
|
||||
|
||||
@@ -29,7 +29,8 @@ type Imp struct {
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Documents documents.Config `yaml:"documents"`
|
||||
|
||||
Documents documents.Config `yaml:"documents"`
|
||||
}
|
||||
|
||||
// Create initialises the billing documents server implementation.
|
||||
@@ -46,6 +47,7 @@ func (i *Imp) Shutdown() {
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.config = cfg
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
@@ -77,20 +80,23 @@ func (i *Imp) Start() error {
|
||||
docStore, err := docstore.New(i.logger, cfg.Documents.Storage)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to initialise document storage", zap.Error(err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { //nolint:lll // factory signature dictated by grpcapp
|
||||
invokeURI := ""
|
||||
if cfg.GRPC != nil {
|
||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||
}
|
||||
|
||||
svc := documents.NewService(logger, repo, producer,
|
||||
documents.WithDiscoveryInvokeURI(invokeURI),
|
||||
documents.WithConfig(cfg.Documents),
|
||||
documents.WithDocumentStore(docStore),
|
||||
)
|
||||
i.service = svc
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
@@ -98,6 +104,7 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
@@ -107,12 +114,14 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &config{Config: &grpcapp.Config{}}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -29,5 +29,6 @@ func (c Config) AcceptanceTemplatePath() string {
|
||||
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
||||
return "templates/acceptance.tpl"
|
||||
}
|
||||
|
||||
return c.Templates.AcceptancePath
|
||||
}
|
||||
|
||||
@@ -85,14 +85,18 @@ func statusFromError(err error) string {
|
||||
if err == nil {
|
||||
return "success"
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return "error"
|
||||
}
|
||||
|
||||
code := st.Code()
|
||||
|
||||
if code == codes.OK {
|
||||
return "success"
|
||||
}
|
||||
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
|
||||
@@ -101,5 +105,6 @@ func docTypeLabel(docType documentsv1.DocumentType) string {
|
||||
if label == "" {
|
||||
return "DOCUMENT_TYPE_UNSPECIFIED"
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ func WithDiscoveryInvokeURI(uri string) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.invokeURI = strings.TrimSpace(uri)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +52,7 @@ func WithProducer(producer msg.Producer) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.producer = producer
|
||||
}
|
||||
}
|
||||
@@ -61,6 +63,7 @@ func WithConfig(cfg Config) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.config = cfg
|
||||
}
|
||||
}
|
||||
@@ -71,6 +74,7 @@ func WithDocumentStore(store docstore.Store) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.docStore = store
|
||||
}
|
||||
}
|
||||
@@ -81,12 +85,15 @@ func WithTemplateRenderer(renderer TemplateRenderer) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.template = renderer
|
||||
}
|
||||
}
|
||||
|
||||
// Service provides billing document metadata and retrieval endpoints.
|
||||
type Service struct {
|
||||
documentsv1.UnimplementedDocumentServiceServer
|
||||
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
docStore docstore.Store
|
||||
@@ -95,12 +102,12 @@ type Service struct {
|
||||
invokeURI string
|
||||
config Config
|
||||
template TemplateRenderer
|
||||
documentsv1.UnimplementedDocumentServiceServer
|
||||
}
|
||||
|
||||
// NewService constructs a documents service with optional configuration.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||
initMetrics()
|
||||
|
||||
svc := &Service{
|
||||
logger: logger.Named("documents"),
|
||||
storage: repo,
|
||||
@@ -109,6 +116,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
|
||||
if svc.template == nil {
|
||||
if tmpl, err := newTemplateRenderer(svc.config.AcceptanceTemplatePath()); err != nil {
|
||||
svc.logger.Warn("Failed to load acceptance template", zap.Error(err))
|
||||
@@ -116,7 +124,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
svc.template = tmpl
|
||||
}
|
||||
}
|
||||
|
||||
svc.startDiscoveryAnnouncer()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -130,32 +140,22 @@ func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_DOCUMENTS",
|
||||
Operations: []string{"documents.batch_resolve", "documents.get"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.BillingDocuments), announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
||||
start := time.Now()
|
||||
|
||||
var paymentRefs []string
|
||||
if req != nil {
|
||||
paymentRefs = req.GetPaymentRefs()
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
|
||||
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||
@@ -165,38 +165,48 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
||||
if resp != nil {
|
||||
itemsCount = len(resp.GetItems())
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Int("items", itemsCount),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("BatchResolveDocuments finished", fields...)
|
||||
}()
|
||||
|
||||
if len(paymentRefs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(paymentRefs))
|
||||
for _, ref := range paymentRefs {
|
||||
clean := strings.TrimSpace(ref)
|
||||
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
refs = append(refs, clean)
|
||||
}
|
||||
|
||||
if len(refs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -206,10 +216,12 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
||||
}
|
||||
|
||||
recordByRef := map[string]*model.DocumentRecord{}
|
||||
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordByRef[record.PaymentRef] = record
|
||||
}
|
||||
|
||||
@@ -218,18 +230,23 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
||||
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
||||
if record := recordByRef[ref]; record != nil {
|
||||
record.Normalize()
|
||||
|
||||
available := []model.DocumentType{model.DocumentTypeAct}
|
||||
|
||||
ready := make([]model.DocumentType, 0, 1)
|
||||
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
|
||||
ready = append(ready, model.DocumentTypeAct)
|
||||
}
|
||||
|
||||
meta.AvailableTypes = toProtoTypes(available)
|
||||
meta.ReadyTypes = toProtoTypes(ready)
|
||||
}
|
||||
|
||||
items = append(items, meta)
|
||||
}
|
||||
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -237,10 +254,12 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
start := time.Now()
|
||||
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||
paymentRef := ""
|
||||
|
||||
if req != nil {
|
||||
docType = req.GetType()
|
||||
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
||||
}
|
||||
|
||||
logger := s.logger.With(
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("document_type", docTypeLabel(docType)),
|
||||
@@ -249,6 +268,7 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
||||
|
||||
if resp != nil {
|
||||
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||
}
|
||||
@@ -257,36 +277,49 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
if resp != nil {
|
||||
contentBytes = len(resp.GetContent())
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Int("content_bytes", contentBytes),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warn("GetDocument failed", append(fields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("GetDocument finished", fields...)
|
||||
}()
|
||||
|
||||
if paymentRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "payment_ref is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
|
||||
err = status.Error(codes.InvalidArgument, "document type is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.docStore == nil {
|
||||
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.template == nil {
|
||||
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -295,8 +328,10 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "document record not found")
|
||||
}
|
||||
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
record.Normalize()
|
||||
|
||||
targetType := model.DocumentTypeFromProto(docType)
|
||||
@@ -310,6 +345,7 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
if loadErr != nil {
|
||||
return nil, status.Error(codes.Internal, loadErr.Error())
|
||||
}
|
||||
|
||||
return &documentsv1.GetDocumentResponse{
|
||||
Content: content,
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
@@ -320,19 +356,23 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
||||
if genErr != nil {
|
||||
logger.Warn("Failed to generate document", zap.Error(genErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, genErr.Error())
|
||||
}
|
||||
|
||||
path := documentStoragePath(paymentRef, docType)
|
||||
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
|
||||
logger.Warn("Failed to store document", zap.Error(saveErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, saveErr.Error())
|
||||
}
|
||||
|
||||
record.StoragePaths[targetType] = path
|
||||
record.Hashes[targetType] = hash
|
||||
|
||||
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
||||
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, updateErr.Error())
|
||||
}
|
||||
|
||||
@@ -341,9 +381,25 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
MimeType: "application/pdf",
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_DOCUMENTS",
|
||||
Operations: []string{"documents.batch_resolve", "documents.get"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.BillingDocuments, announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
type serviceError string
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
@@ -361,15 +417,18 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
generated := renderer.Renderer{
|
||||
Issuer: s.config.Issuer,
|
||||
OwnerPassword: s.config.Protection.OwnerPassword,
|
||||
}
|
||||
placeholder := strings.Repeat("0", 64)
|
||||
|
||||
firstPass, err := generated.Render(blocks, placeholder)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
footerHash := sha256.Sum256(firstPass)
|
||||
footerHex := hex.EncodeToString(footerHash[:])
|
||||
|
||||
@@ -377,6 +436,7 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return finalBytes, footerHex, nil
|
||||
}
|
||||
|
||||
@@ -384,15 +444,18 @@ func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
||||
if len(types) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]documentsv1.DocumentType, 0, len(types))
|
||||
for _, t := range types {
|
||||
result = append(result, t.Proto())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
|
||||
suffix := "document.pdf"
|
||||
|
||||
switch docType {
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||
suffix = "act.pdf"
|
||||
@@ -400,12 +463,16 @@ func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) st
|
||||
suffix = "invoice.pdf"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||
suffix = "receipt.pdf"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||
// default suffix used
|
||||
}
|
||||
|
||||
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
|
||||
}
|
||||
|
||||
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
|
||||
name := "document"
|
||||
|
||||
switch docType {
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||
name = "act"
|
||||
@@ -413,6 +480,9 @@ func documentFilename(docType documentsv1.DocumentType, paymentRef string) strin
|
||||
name = "invoice"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||
name = "receipt"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||
// default name used
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type stubRepo struct {
|
||||
store storage.DocumentsStore
|
||||
}
|
||||
|
||||
func (s *stubRepo) Ping(ctx context.Context) error { return nil }
|
||||
func (s *stubRepo) Ping(_ context.Context) error { return nil }
|
||||
func (s *stubRepo) Documents() storage.DocumentsStore { return s.store }
|
||||
|
||||
var _ storage.Repository = (*stubRepo)(nil)
|
||||
@@ -28,22 +28,24 @@ type stubDocumentsStore struct {
|
||||
updateCalls int
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) Create(ctx context.Context, record *model.DocumentRecord) error {
|
||||
func (s *stubDocumentsStore) Create(_ context.Context, record *model.DocumentRecord) error {
|
||||
s.record = record
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) Update(ctx context.Context, record *model.DocumentRecord) error {
|
||||
func (s *stubDocumentsStore) Update(_ context.Context, record *model.DocumentRecord) error {
|
||||
s.record = record
|
||||
s.updateCalls++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error) {
|
||||
func (s *stubDocumentsStore) GetByPaymentRef(_ context.Context, _ string) (*model.DocumentRecord, error) {
|
||||
return s.record, nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error) {
|
||||
func (s *stubDocumentsStore) ListByPaymentRefs(_ context.Context, _ []string) ([]*model.DocumentRecord, error) {
|
||||
return []*model.DocumentRecord{s.record}, nil
|
||||
}
|
||||
|
||||
@@ -59,19 +61,21 @@ func newMemDocStore() *memDocStore {
|
||||
return &memDocStore{data: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (m *memDocStore) Save(ctx context.Context, key string, data []byte) error {
|
||||
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
|
||||
m.saveCount++
|
||||
copyData := make([]byte, len(data))
|
||||
copy(copyData, data)
|
||||
m.data[key] = copyData
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDocStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
func (m *memDocStore) Load(_ context.Context, key string) ([]byte, error) {
|
||||
m.loadCount++
|
||||
data := m.data[key]
|
||||
copyData := make([]byte, len(data))
|
||||
copy(copyData, data)
|
||||
|
||||
return copyData, nil
|
||||
}
|
||||
|
||||
@@ -84,8 +88,9 @@ type stubTemplate struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubTemplate) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||
func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
|
||||
s.calls++
|
||||
|
||||
return s.blocks, nil
|
||||
}
|
||||
|
||||
@@ -135,18 +140,23 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument first call: %v", err)
|
||||
}
|
||||
if len(resp1.Content) == 0 {
|
||||
|
||||
if len(resp1.GetContent()) == 0 {
|
||||
t.Fatalf("expected content on first call")
|
||||
}
|
||||
|
||||
stored := record.Hashes[model.DocumentTypeAct]
|
||||
|
||||
if stored == "" {
|
||||
t.Fatalf("expected stored hash")
|
||||
}
|
||||
footerHash := extractFooterHash(resp1.Content)
|
||||
|
||||
footerHash := extractFooterHash(resp1.GetContent())
|
||||
|
||||
if footerHash == "" {
|
||||
t.Fatalf("expected footer hash in PDF")
|
||||
}
|
||||
|
||||
if stored != footerHash {
|
||||
t.Fatalf("stored hash mismatch: got %s", stored)
|
||||
}
|
||||
@@ -158,16 +168,19 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument second call: %v", err)
|
||||
}
|
||||
if !bytes.Equal(resp1.Content, resp2.Content) {
|
||||
|
||||
if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) {
|
||||
t.Fatalf("expected identical PDF bytes on second call")
|
||||
}
|
||||
|
||||
if tmpl.calls != 1 {
|
||||
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
|
||||
}
|
||||
|
||||
if store.saveCount != 1 {
|
||||
t.Fatalf("expected document save once, got %d", store.saveCount)
|
||||
}
|
||||
|
||||
if store.loadCount == 0 {
|
||||
t.Fatalf("expected document load on second call")
|
||||
}
|
||||
@@ -176,17 +189,23 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
func extractFooterHash(pdf []byte) string {
|
||||
prefix := []byte("Document integrity hash: ")
|
||||
idx := bytes.Index(pdf, prefix)
|
||||
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
start := idx + len(prefix)
|
||||
|
||||
end := start
|
||||
|
||||
for end < len(pdf) && isHexDigit(pdf[end]) {
|
||||
end++
|
||||
}
|
||||
|
||||
if end-start != 64 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(pdf[start:end])
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type templateRenderer struct {
|
||||
func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read template: %w", err)
|
||||
return nil, fmt.Errorf("read template: %w", err)
|
||||
}
|
||||
|
||||
funcs := template.FuncMap{
|
||||
@@ -30,7 +30,7 @@ func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||
|
||||
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 nil, fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
return &templateRenderer{tpl: tpl}, nil
|
||||
@@ -39,8 +39,9 @@ func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||
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 nil, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
return renderer.ParseBlocks(buf.String())
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ func formatMoney(amount decimal.Decimal, currency string) string {
|
||||
if currency == "" {
|
||||
return amount.String()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s", amount.String(), currency)
|
||||
}
|
||||
|
||||
@@ -56,5 +58,6 @@ func formatDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package documents
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
func TestTemplateRenderer_Render(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "..", "templates", "acceptance.tpl")
|
||||
|
||||
tmpl, err := newTemplateRenderer(path)
|
||||
if err != nil {
|
||||
t.Fatalf("newTemplateRenderer: %v", err)
|
||||
@@ -29,22 +31,18 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
t.Fatalf("expected blocks, got none")
|
||||
}
|
||||
|
||||
title := findBlock(blocks, renderer.TagTitle)
|
||||
|
||||
if title == nil {
|
||||
t.Fatalf("expected title block")
|
||||
}
|
||||
foundTitle := false
|
||||
for _, line := range title.Lines {
|
||||
if line == "ACT OF ACCEPTANCE OF SERVICES" {
|
||||
foundTitle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundTitle {
|
||||
|
||||
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
|
||||
t.Fatalf("expected title content not found")
|
||||
}
|
||||
|
||||
@@ -52,13 +50,17 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if kv == nil {
|
||||
t.Fatalf("expected kv block")
|
||||
}
|
||||
|
||||
foundExecutor := false
|
||||
|
||||
for _, row := range kv.Rows {
|
||||
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
|
||||
foundExecutor = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundExecutor {
|
||||
t.Fatalf("expected executor name in kv block")
|
||||
}
|
||||
@@ -67,13 +69,17 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if table == nil {
|
||||
t.Fatalf("expected table block")
|
||||
}
|
||||
|
||||
foundAmount := false
|
||||
|
||||
for _, row := range table.Rows {
|
||||
if len(row) >= 2 && row[1] == "123.45 USD" {
|
||||
foundAmount = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundAmount {
|
||||
t.Fatalf("expected amount in table block")
|
||||
}
|
||||
@@ -85,5 +91,6 @@ func findBlock(blocks []renderer.Block, tag renderer.Tag) *renderer.Block {
|
||||
return &blocks[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user