Fixes + stable gateway ids

This commit is contained in:
Stephan D
2026-02-18 20:38:08 +01:00
parent 4dc182bfa2
commit 770c7b9da9
119 changed files with 3000 additions and 734 deletions

View File

@@ -29,5 +29,6 @@ func (c Config) AcceptanceTemplatePath() string {
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
return "templates/acceptance.tpl"
}
return c.Templates.AcceptancePath
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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])
}

View File

@@ -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")
}

View File

@@ -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
}