587 lines
15 KiB
Go
587 lines
15 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
|
|
ClientName string
|
|
ClientAddress 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()),
|
|
ClientName: strings.TrimSpace(req.GetClientName()),
|
|
ClientAddress: strings.TrimSpace(req.GetClientAddress()),
|
|
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
|
|
|
|
blocks := []renderer.Block{
|
|
{
|
|
Tag: renderer.TagTitle,
|
|
Lines: []string{documentCopy.Title},
|
|
},
|
|
{
|
|
Tag: renderer.TagSubtitle,
|
|
Lines: []string{documentCopy.Subtitle},
|
|
},
|
|
{
|
|
Tag: renderer.TagMeta,
|
|
Lines: []string{
|
|
fmt.Sprintf("%s: %s", documentCopy.MetaCertificateNumberLabel, certificateNumber(snapshot)),
|
|
fmt.Sprintf("%s: %s", documentCopy.MetaDateLabel, formatCertificateDate(certificateDate(snapshot))),
|
|
},
|
|
},
|
|
{
|
|
Tag: renderer.TagSection,
|
|
Lines: []string{documentCopy.SectionParties},
|
|
},
|
|
{
|
|
Tag: renderer.TagText,
|
|
Lines: []string{documentCopy.PartiesIntro},
|
|
},
|
|
{
|
|
Tag: renderer.TagKV,
|
|
Rows: [][]string{
|
|
{documentCopy.RowServiceProvider, content.IssuerLegalName},
|
|
{documentCopy.RowServiceProviderAddress, content.IssuerLegalAddress},
|
|
{documentCopy.RowServiceProviderEmail, content.IssuerEmail},
|
|
{documentCopy.RowClient, certificateClientName(snapshot)},
|
|
{documentCopy.RowClientAddress, certificateClientAddress(snapshot)},
|
|
{documentCopy.RowClientReference, safeValue(snapshot.PaymentRef)},
|
|
},
|
|
},
|
|
{
|
|
Tag: renderer.TagSection,
|
|
Lines: []string{documentCopy.SectionSubject},
|
|
},
|
|
{
|
|
Tag: renderer.TagText,
|
|
Lines: []string{
|
|
documentCopy.SubjectIntro,
|
|
"",
|
|
"- Payment execution and orchestration services for payment reference " + safeValue(snapshot.PaymentRef) + ".",
|
|
"- Gateway service: " + safeValue(snapshot.GatewayService) + ".",
|
|
"- Operation reference: " + safeValue(snapshot.OperationRef) + ".",
|
|
"- Operation descriptor: " + operationDescriptor(snapshot) + ".",
|
|
},
|
|
},
|
|
{
|
|
Tag: renderer.TagSection,
|
|
Lines: []string{documentCopy.SectionServicePeriod},
|
|
},
|
|
{
|
|
Tag: renderer.TagKV,
|
|
Rows: [][]string{
|
|
{documentCopy.RowPeriodFrom, formatSnapshotTime(snapshot.StartedAt)},
|
|
{documentCopy.RowPeriodTo, formatSnapshotTime(snapshot.CompletedAt)},
|
|
},
|
|
},
|
|
{
|
|
Tag: renderer.TagSection,
|
|
Lines: []string{documentCopy.SectionTotalAmount},
|
|
},
|
|
{
|
|
Tag: renderer.TagKV,
|
|
Rows: [][]string{
|
|
{documentCopy.RowTotalAmount, operationAmount(snapshot)},
|
|
},
|
|
},
|
|
{
|
|
Tag: renderer.TagSection,
|
|
Lines: []string{documentCopy.SectionClientConfirmation},
|
|
},
|
|
{
|
|
Tag: renderer.TagText,
|
|
Lines: []string{
|
|
documentCopy.ConfirmationLine1,
|
|
documentCopy.ConfirmationLine2,
|
|
documentCopy.ConfirmationLine3,
|
|
"",
|
|
"This Certificate serves as confirmation of the completion and acceptance of the services.",
|
|
},
|
|
},
|
|
{
|
|
Tag: renderer.TagSection,
|
|
Lines: []string{documentCopy.SectionSignatures},
|
|
},
|
|
{
|
|
Tag: renderer.TagSign,
|
|
Lines: []string{
|
|
documentCopy.SignatureServiceProviderLine,
|
|
"",
|
|
documentCopy.SignatureClientNamePrefix + " " + certificateClientName(snapshot),
|
|
documentCopy.SignatureClientTitleLine,
|
|
documentCopy.SignatureClientLine,
|
|
},
|
|
},
|
|
{
|
|
Tag: renderer.TagSection,
|
|
Lines: []string{documentCopy.SectionOperationStatus},
|
|
},
|
|
{
|
|
Tag: renderer.TagKV,
|
|
Rows: [][]string{
|
|
{documentCopy.RowOperationStatus, safeValue(snapshot.OperationState)},
|
|
{documentCopy.RowOperationCode, safeValue(snapshot.OperationCode)},
|
|
{documentCopy.RowOperationLabel, safeValue(snapshot.OperationLabel)},
|
|
},
|
|
},
|
|
}
|
|
|
|
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
|
|
blocks = append(blocks, 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 certificateNumber(snapshot operationSnapshot) string {
|
|
if paymentRef := strings.TrimSpace(snapshot.PaymentRef); paymentRef != "" {
|
|
return paymentRef
|
|
}
|
|
|
|
if operationRef := strings.TrimSpace(snapshot.OperationRef); operationRef != "" {
|
|
return operationRef
|
|
}
|
|
|
|
return content.OperationDocument.MissingValuePlaceholder
|
|
}
|
|
|
|
func certificateDate(snapshot operationSnapshot) time.Time {
|
|
if !snapshot.CompletedAt.IsZero() {
|
|
return snapshot.CompletedAt.UTC()
|
|
}
|
|
|
|
if !snapshot.StartedAt.IsZero() {
|
|
return snapshot.StartedAt.UTC()
|
|
}
|
|
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func formatCertificateDate(value time.Time) string {
|
|
if value.IsZero() {
|
|
return content.OperationDocument.MissingValuePlaceholder
|
|
}
|
|
|
|
return value.UTC().Format("January 2, 2006")
|
|
}
|
|
|
|
func operationAmount(snapshot operationSnapshot) string {
|
|
amount := strings.TrimSpace(snapshot.Amount)
|
|
currency := strings.TrimSpace(snapshot.Currency)
|
|
if amount == "" && currency == "" {
|
|
return content.OperationDocument.MissingValuePlaceholder
|
|
}
|
|
|
|
return strings.TrimSpace(amount + " " + currency)
|
|
}
|
|
|
|
func operationDescriptor(snapshot operationSnapshot) string {
|
|
label := strings.TrimSpace(snapshot.OperationLabel)
|
|
code := strings.TrimSpace(snapshot.OperationCode)
|
|
|
|
switch {
|
|
case label != "" && code != "":
|
|
return fmt.Sprintf("%s (%s)", label, code)
|
|
case label != "":
|
|
return label
|
|
case code != "":
|
|
return code
|
|
default:
|
|
return content.OperationDocument.MissingValuePlaceholder
|
|
}
|
|
}
|
|
|
|
func certificateClientName(snapshot operationSnapshot) string {
|
|
if name := strings.TrimSpace(snapshot.ClientName); name != "" {
|
|
return name
|
|
}
|
|
|
|
return "John Doe"
|
|
}
|
|
|
|
func certificateClientAddress(snapshot operationSnapshot) string {
|
|
if address := strings.TrimSpace(snapshot.ClientAddress); address != "" {
|
|
return address
|
|
}
|
|
|
|
return content.OperationDocument.MissingValuePlaceholder
|
|
}
|
|
|
|
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(), "_")
|
|
}
|