billing docs service

This commit is contained in:
Stephan D
2026-01-30 15:16:20 +01:00
parent 51f5b0804a
commit 7fbd88b6ef
34 changed files with 2728 additions and 18 deletions

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

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

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

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

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

View File

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