Files
sendico/api/billing/documents/internal/service/documents/service.go
Stephan D e77d1ab793 linting
2026-03-10 12:31:09 +01:00

439 lines
11 KiB
Go

package documents
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/tech/sendico/billing/documents/internal/appversion"
"github.com/tech/sendico/billing/documents/internal/content"
"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 {
documentsv1.UnimplementedDocumentServiceServer
logger mlogger.Logger
storage storage.Repository
docStore docstore.Store
producer msg.Producer
announcer *discovery.Announcer
invokeURI string
config Config
template TemplateRenderer
}
// 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) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
start := time.Now()
organizationRef := ""
gatewayService := ""
operationRef := ""
if req != nil {
organizationRef = strings.TrimSpace(req.GetOrganizationRef())
gatewayService = strings.TrimSpace(req.GetGatewayService())
operationRef = strings.TrimSpace(req.GetOperationRef())
}
logger := s.logger.With(
zap.String("organization_ref", organizationRef),
zap.String("gateway_service", gatewayService),
zap.String("operation_ref", operationRef),
)
defer func() {
statusLabel := statusFromError(err)
observeRequest("get_operation_document", "operation", statusLabel, time.Since(start))
if resp != nil {
observeDocumentBytes("operation", 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("GetOperationDocument failed", append(fields, zap.Error(err))...)
return
}
logger.Info("GetOperationDocument finished", fields...)
}()
if req == nil {
err = status.Error(codes.InvalidArgument, "request is required")
return nil, err
}
if organizationRef == "" {
err = status.Error(codes.InvalidArgument, "organization_ref is required")
return nil, err
}
if gatewayService == "" {
err = status.Error(codes.InvalidArgument, "gateway_service is required")
return nil, err
}
if operationRef == "" {
err = status.Error(codes.InvalidArgument, "operation_ref is required")
return nil, err
}
snapshot := operationSnapshotFromRequest(req)
content, _, genErr := s.generateOperationPDF(snapshot)
if genErr != nil {
err = status.Error(codes.Internal, genErr.Error())
return nil, err
}
resp = &documentsv1.GetDocumentResponse{
Content: content,
Filename: operationDocumentFilename(operationRef),
MimeType: "application/pdf",
}
return resp, nil
}
func (s *Service) startDiscoveryAnnouncer() {
if s == nil || s.producer == nil {
return
}
announce := discovery.Announcement{
Service: mservice.BillingDocuments,
Operations: []string{discovery.OperationDocumentsGet},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.BillingDocuments, announce)
s.announcer.Start()
}
func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, error) {
blocks, err := s.template.Render(snapshot)
if err != nil {
return nil, "", err
}
return s.renderPDFWithIntegrity(blocks)
}
func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, string, error) {
return s.renderPDFWithIntegrity(buildOperationBlocks(snapshot))
}
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
generated := renderer.Renderer{
Issuer: s.config.IssuerDetails(),
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
}
type operationSnapshot struct {
OrganizationRef string
GatewayService string
OperationRef string
PaymentRef string
OperationCode string
OperationLabel string
OperationState string
FailureCode string
FailureReason string
Amount string
Currency string
StartedAt time.Time
CompletedAt time.Time
}
func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest) operationSnapshot {
snapshot := operationSnapshot{
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
GatewayService: strings.TrimSpace(req.GetGatewayService()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
OperationCode: strings.TrimSpace(req.GetOperationCode()),
OperationLabel: strings.TrimSpace(req.GetOperationLabel()),
OperationState: strings.TrimSpace(req.GetOperationState()),
FailureCode: strings.TrimSpace(req.GetFailureCode()),
FailureReason: strings.TrimSpace(req.GetFailureReason()),
Amount: strings.TrimSpace(req.GetAmount()),
Currency: strings.TrimSpace(req.GetCurrency()),
}
if ts := req.GetStartedAtUnixMs(); ts > 0 {
snapshot.StartedAt = time.UnixMilli(ts).UTC()
}
if ts := req.GetCompletedAtUnixMs(); ts > 0 {
snapshot.CompletedAt = time.UnixMilli(ts).UTC()
}
return snapshot
}
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
documentCopy := content.OperationDocument
rows := [][]string{
{documentCopy.RowOrganization, snapshot.OrganizationRef},
{documentCopy.RowGatewayService, snapshot.GatewayService},
{documentCopy.RowOperationRef, snapshot.OperationRef},
{documentCopy.RowPaymentRef, safeValue(snapshot.PaymentRef)},
{documentCopy.RowCode, safeValue(snapshot.OperationCode)},
{documentCopy.RowState, safeValue(snapshot.OperationState)},
{documentCopy.RowLabel, safeValue(snapshot.OperationLabel)},
{documentCopy.RowStartedAtUTC, formatSnapshotTime(snapshot.StartedAt)},
{documentCopy.RowCompletedAtUTC, formatSnapshotTime(snapshot.CompletedAt)},
}
if snapshot.Amount != "" || snapshot.Currency != "" {
rows = append(rows, []string{documentCopy.RowAmount, strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
}
blocks := []renderer.Block{
{
Tag: renderer.TagTitle,
Lines: []string{documentCopy.Title},
},
{
Tag: renderer.TagSubtitle,
Lines: []string{documentCopy.Subtitle},
},
{
Tag: renderer.TagMeta,
Lines: []string{
documentCopy.MetaDocumentType,
},
},
{
Tag: renderer.TagSection,
Lines: []string{documentCopy.SectionOperation},
},
{
Tag: renderer.TagKV,
Rows: rows,
},
}
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
blocks = append(blocks,
renderer.Block{Tag: renderer.TagSection, Lines: []string{documentCopy.SectionFailure}},
renderer.Block{
Tag: renderer.TagKV,
Rows: [][]string{
{documentCopy.RowFailureCode, safeValue(snapshot.FailureCode)},
{documentCopy.RowFailureReason, safeValue(snapshot.FailureReason)},
},
},
)
}
return blocks
}
func formatSnapshotTime(value time.Time) string {
if value.IsZero() {
return content.OperationDocument.MissingValuePlaceholder
}
return value.UTC().Format(time.RFC3339)
}
func safeValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return content.OperationDocument.MissingValuePlaceholder
}
return trimmed
}
func operationDocumentFilename(operationRef string) string {
clean := sanitizeFilenameComponent(operationRef)
if clean == "" {
clean = "operation"
}
return fmt.Sprintf("operation_%s.pdf", clean)
}
func sanitizeFilenameComponent(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
var b strings.Builder
b.Grow(len(trimmed))
for _, r := range trimmed {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return strings.Trim(b.String(), "_")
}