419 lines
11 KiB
Go
419 lines
11 KiB
Go
package documents
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tech/sendico/billing/documents/internal/appversion"
|
|
"github.com/tech/sendico/billing/documents/internal/docstore"
|
|
"github.com/tech/sendico/billing/documents/renderer"
|
|
"github.com/tech/sendico/billing/documents/storage"
|
|
"github.com/tech/sendico/billing/documents/storage/model"
|
|
"github.com/tech/sendico/pkg/api/routers"
|
|
"github.com/tech/sendico/pkg/discovery"
|
|
msg "github.com/tech/sendico/pkg/messaging"
|
|
"github.com/tech/sendico/pkg/mlogger"
|
|
"github.com/tech/sendico/pkg/mservice"
|
|
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// TemplateRenderer renders the acceptance template into tagged blocks.
|
|
type TemplateRenderer interface {
|
|
Render(snapshot model.ActSnapshot) ([]renderer.Block, error)
|
|
}
|
|
|
|
// Option configures the documents service.
|
|
type Option func(*Service)
|
|
|
|
// WithDiscoveryInvokeURI configures the discovery invoke URI.
|
|
func WithDiscoveryInvokeURI(uri string) Option {
|
|
return func(s *Service) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.invokeURI = strings.TrimSpace(uri)
|
|
}
|
|
}
|
|
|
|
// WithProducer sets the messaging producer.
|
|
func WithProducer(producer msg.Producer) Option {
|
|
return func(s *Service) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.producer = producer
|
|
}
|
|
}
|
|
|
|
// WithConfig sets the service config.
|
|
func WithConfig(cfg Config) Option {
|
|
return func(s *Service) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.config = cfg
|
|
}
|
|
}
|
|
|
|
// WithDocumentStore sets the document storage backend.
|
|
func WithDocumentStore(store docstore.Store) Option {
|
|
return func(s *Service) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.docStore = store
|
|
}
|
|
}
|
|
|
|
// WithTemplateRenderer overrides the template renderer (useful for tests).
|
|
func WithTemplateRenderer(renderer TemplateRenderer) Option {
|
|
return func(s *Service) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.template = renderer
|
|
}
|
|
}
|
|
|
|
// Service provides billing document metadata and retrieval endpoints.
|
|
type Service struct {
|
|
logger mlogger.Logger
|
|
storage storage.Repository
|
|
docStore docstore.Store
|
|
producer msg.Producer
|
|
announcer *discovery.Announcer
|
|
invokeURI string
|
|
config Config
|
|
template TemplateRenderer
|
|
documentsv1.UnimplementedDocumentServiceServer
|
|
}
|
|
|
|
// NewService constructs a documents service with optional configuration.
|
|
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
|
initMetrics()
|
|
svc := &Service{
|
|
logger: logger.Named("documents"),
|
|
storage: repo,
|
|
producer: producer,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(svc)
|
|
}
|
|
if svc.template == nil {
|
|
if tmpl, err := newTemplateRenderer(svc.config.AcceptanceTemplatePath()); err != nil {
|
|
svc.logger.Warn("failed to load acceptance template", zap.Error(err))
|
|
} else {
|
|
svc.template = tmpl
|
|
}
|
|
}
|
|
svc.startDiscoveryAnnouncer()
|
|
return svc
|
|
}
|
|
|
|
func (s *Service) Register(router routers.GRPC) error {
|
|
return router.Register(func(reg grpc.ServiceRegistrar) {
|
|
documentsv1.RegisterDocumentServiceServer(reg, s)
|
|
})
|
|
}
|
|
|
|
func (s *Service) Shutdown() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
if s.announcer != nil {
|
|
s.announcer.Stop()
|
|
}
|
|
}
|
|
|
|
func (s *Service) startDiscoveryAnnouncer() {
|
|
if s == nil || s.producer == nil {
|
|
return
|
|
}
|
|
announce := discovery.Announcement{
|
|
Service: "BILLING_DOCUMENTS",
|
|
Operations: []string{"documents.batch_resolve", "documents.get"},
|
|
InvokeURI: s.invokeURI,
|
|
Version: appversion.Create().Short(),
|
|
}
|
|
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.BillingDocuments), announce)
|
|
s.announcer.Start()
|
|
}
|
|
|
|
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
|
start := time.Now()
|
|
var paymentRefs []string
|
|
if req != nil {
|
|
paymentRefs = req.GetPaymentRefs()
|
|
}
|
|
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
|
|
defer func() {
|
|
statusLabel := statusFromError(err)
|
|
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
|
observeBatchSize(len(paymentRefs))
|
|
|
|
itemsCount := 0
|
|
if resp != nil {
|
|
itemsCount = len(resp.GetItems())
|
|
}
|
|
fields := []zap.Field{
|
|
zap.String("status", statusLabel),
|
|
zap.Duration("duration", time.Since(start)),
|
|
zap.Int("items", itemsCount),
|
|
}
|
|
if err != nil {
|
|
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
|
|
return
|
|
}
|
|
logger.Info("BatchResolveDocuments finished", fields...)
|
|
}()
|
|
|
|
if len(paymentRefs) == 0 {
|
|
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
|
return resp, nil
|
|
}
|
|
|
|
if s.storage == nil {
|
|
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
|
return nil, err
|
|
}
|
|
|
|
refs := make([]string, 0, len(paymentRefs))
|
|
for _, ref := range paymentRefs {
|
|
clean := strings.TrimSpace(ref)
|
|
if clean == "" {
|
|
continue
|
|
}
|
|
refs = append(refs, clean)
|
|
}
|
|
if len(refs) == 0 {
|
|
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
|
return resp, nil
|
|
}
|
|
|
|
records, err := s.storage.Documents().ListByPaymentRefs(ctx, refs)
|
|
if err != nil {
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
|
|
recordByRef := map[string]*model.DocumentRecord{}
|
|
for _, record := range records {
|
|
if record == nil {
|
|
continue
|
|
}
|
|
recordByRef[record.PaymentRef] = record
|
|
}
|
|
|
|
items := make([]*documentsv1.DocumentMeta, 0, len(refs))
|
|
for _, ref := range refs {
|
|
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
|
if record := recordByRef[ref]; record != nil {
|
|
record.Normalize()
|
|
available := []model.DocumentType{model.DocumentTypeAct}
|
|
ready := make([]model.DocumentType, 0, 1)
|
|
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
|
|
ready = append(ready, model.DocumentTypeAct)
|
|
}
|
|
meta.AvailableTypes = toProtoTypes(available)
|
|
meta.ReadyTypes = toProtoTypes(ready)
|
|
}
|
|
items = append(items, meta)
|
|
}
|
|
|
|
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
|
start := time.Now()
|
|
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
|
paymentRef := ""
|
|
if req != nil {
|
|
docType = req.GetType()
|
|
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
|
}
|
|
logger := s.logger.With(
|
|
zap.String("payment_ref", paymentRef),
|
|
zap.String("document_type", docTypeLabel(docType)),
|
|
)
|
|
|
|
defer func() {
|
|
statusLabel := statusFromError(err)
|
|
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
|
if resp != nil {
|
|
observeDocumentBytes(docType, len(resp.GetContent()))
|
|
}
|
|
|
|
contentBytes := 0
|
|
if resp != nil {
|
|
contentBytes = len(resp.GetContent())
|
|
}
|
|
fields := []zap.Field{
|
|
zap.String("status", statusLabel),
|
|
zap.Duration("duration", time.Since(start)),
|
|
zap.Int("content_bytes", contentBytes),
|
|
}
|
|
if err != nil {
|
|
logger.Warn("GetDocument failed", append(fields, zap.Error(err))...)
|
|
return
|
|
}
|
|
logger.Info("GetDocument finished", fields...)
|
|
}()
|
|
|
|
if paymentRef == "" {
|
|
err = status.Error(codes.InvalidArgument, "payment_ref is required")
|
|
return nil, err
|
|
}
|
|
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
|
|
err = status.Error(codes.InvalidArgument, "document type is required")
|
|
return nil, err
|
|
}
|
|
if s.storage == nil {
|
|
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
|
return nil, err
|
|
}
|
|
if s.docStore == nil {
|
|
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
|
|
return nil, err
|
|
}
|
|
if s.template == nil {
|
|
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
|
|
return nil, err
|
|
}
|
|
|
|
record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef)
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrDocumentNotFound) {
|
|
return nil, status.Error(codes.NotFound, "document record not found")
|
|
}
|
|
return nil, status.Error(codes.Internal, err.Error())
|
|
}
|
|
record.Normalize()
|
|
|
|
targetType := model.DocumentTypeFromProto(docType)
|
|
|
|
if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT {
|
|
return nil, status.Error(codes.Unimplemented, "document type not implemented")
|
|
}
|
|
|
|
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
|
|
content, loadErr := s.docStore.Load(ctx, path)
|
|
if loadErr != nil {
|
|
return nil, status.Error(codes.Internal, loadErr.Error())
|
|
}
|
|
return &documentsv1.GetDocumentResponse{
|
|
Content: content,
|
|
Filename: documentFilename(docType, paymentRef),
|
|
MimeType: "application/pdf",
|
|
}, nil
|
|
}
|
|
|
|
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
|
if genErr != nil {
|
|
logger.Warn("Failed to generate document", zap.Error(genErr))
|
|
return nil, status.Error(codes.Internal, genErr.Error())
|
|
}
|
|
|
|
path := documentStoragePath(paymentRef, docType)
|
|
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
|
|
logger.Warn("Failed to store document", zap.Error(saveErr))
|
|
return nil, status.Error(codes.Internal, saveErr.Error())
|
|
}
|
|
|
|
record.StoragePaths[targetType] = path
|
|
record.Hashes[targetType] = hash
|
|
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
|
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
|
return nil, status.Error(codes.Internal, updateErr.Error())
|
|
}
|
|
|
|
resp = &documentsv1.GetDocumentResponse{
|
|
Content: content,
|
|
Filename: documentFilename(docType, paymentRef),
|
|
MimeType: "application/pdf",
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
type serviceError string
|
|
|
|
func (e serviceError) Error() string {
|
|
return string(e)
|
|
}
|
|
|
|
var (
|
|
errStorageUnavailable = serviceError("documents: storage not initialised")
|
|
errDocStoreUnavailable = serviceError("documents: document store not initialised")
|
|
errTemplateUnavailable = serviceError("documents: template renderer not initialised")
|
|
)
|
|
|
|
func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, error) {
|
|
blocks, err := s.template.Render(snapshot)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
generated := renderer.Renderer{
|
|
Issuer: s.config.Issuer,
|
|
OwnerPassword: s.config.Protection.OwnerPassword,
|
|
}
|
|
placeholder := strings.Repeat("0", 64)
|
|
firstPass, err := generated.Render(blocks, placeholder)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
footerHash := sha256.Sum256(firstPass)
|
|
footerHex := hex.EncodeToString(footerHash[:])
|
|
|
|
finalBytes, err := generated.Render(blocks, footerHex)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return finalBytes, footerHex, nil
|
|
}
|
|
|
|
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
|
if len(types) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]documentsv1.DocumentType, 0, len(types))
|
|
for _, t := range types {
|
|
result = append(result, t.Proto())
|
|
}
|
|
return result
|
|
}
|
|
|
|
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
|
|
suffix := "document.pdf"
|
|
switch docType {
|
|
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
|
suffix = "act.pdf"
|
|
case documentsv1.DocumentType_DOCUMENT_TYPE_INVOICE:
|
|
suffix = "invoice.pdf"
|
|
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
|
suffix = "receipt.pdf"
|
|
}
|
|
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
|
|
}
|
|
|
|
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
|
|
name := "document"
|
|
switch docType {
|
|
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
|
name = "act"
|
|
case documentsv1.DocumentType_DOCUMENT_TYPE_INVOICE:
|
|
name = "invoice"
|
|
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
|
name = "receipt"
|
|
}
|
|
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
|
}
|