billing docs service
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user