billing docs service
This commit is contained in:
50
api/billing/documents/renderer/header.go
Normal file
50
api/billing/documents/renderer/header.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
)
|
||||
|
||||
// Issuer describes the document issuer.
|
||||
type Issuer struct {
|
||||
LegalName string `yaml:"legal_name"`
|
||||
LegalAddress string `yaml:"legal_address"`
|
||||
LogoPath string `yaml:"logo_path"`
|
||||
}
|
||||
|
||||
func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64) (float64, error) {
|
||||
startX := marginLeft
|
||||
startY := marginTop
|
||||
logoWidth := 0.0
|
||||
|
||||
if strings.TrimSpace(issuer.LogoPath) != "" {
|
||||
logoWidth = 24
|
||||
pdf.ImageOptions(issuer.LogoPath, startX, startY, logoWidth, 0, false, gofpdf.ImageOptions{ReadDpi: true}, 0, "")
|
||||
}
|
||||
|
||||
textX := startX
|
||||
if logoWidth > 0 {
|
||||
textX = startX + logoWidth + 6
|
||||
}
|
||||
pdf.SetXY(textX, startY)
|
||||
pdf.SetFont("Helvetica", "B", 12)
|
||||
pdf.CellFormat(0, 5, issuer.LegalName, "", 1, "L", false, 0, "")
|
||||
pdf.SetX(textX)
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
pdf.MultiCell(0, 4.5, issuer.LegalAddress, "", "L", false)
|
||||
|
||||
if pdf.Error() != nil {
|
||||
return 0, pdf.Error()
|
||||
}
|
||||
|
||||
currentY := pdf.GetY()
|
||||
if logoWidth > 0 {
|
||||
logoBottom := startY + logoWidth
|
||||
if logoBottom > currentY {
|
||||
currentY = logoBottom
|
||||
}
|
||||
}
|
||||
|
||||
return currentY - startY, nil
|
||||
}
|
||||
221
api/billing/documents/renderer/layout.go
Normal file
221
api/billing/documents/renderer/layout.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
)
|
||||
|
||||
const (
|
||||
pageMarginLeft = 20.0
|
||||
pageMarginRight = 20.0
|
||||
pageMarginTop = 20.0
|
||||
pageMarginBottom = 22.0
|
||||
)
|
||||
|
||||
// Renderer builds a PDF document from tagged blocks.
|
||||
type Renderer struct {
|
||||
Issuer Issuer
|
||||
OwnerPassword string
|
||||
}
|
||||
|
||||
// Render generates the PDF bytes for the provided blocks and footer hash.
|
||||
func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
pdf.SetMargins(pageMarginLeft, pageMarginTop, pageMarginRight)
|
||||
pdf.SetAutoPageBreak(true, pageMarginBottom)
|
||||
pdf.SetCompression(false)
|
||||
pdf.SetAuthor(r.Issuer.LegalName, false)
|
||||
pdf.SetTitle("Act of Acceptance", false)
|
||||
|
||||
owner := strings.TrimSpace(r.OwnerPassword)
|
||||
if owner != "" {
|
||||
pdf.SetProtection(gofpdf.CnProtectPrint, "", owner)
|
||||
}
|
||||
|
||||
pdf.SetFooterFunc(func() {
|
||||
pdf.SetY(-15)
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
footer := fmt.Sprintf("Document integrity hash: %s", footerHash)
|
||||
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
||||
})
|
||||
|
||||
pdf.AddPage()
|
||||
if _, err := drawHeader(pdf, r.Issuer, pageMarginLeft, pageMarginTop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pdf.Ln(6)
|
||||
|
||||
for _, block := range blocks {
|
||||
renderBlock(pdf, block)
|
||||
if pdf.Error() != nil {
|
||||
return nil, pdf.Error()
|
||||
}
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := pdf.Output(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||
switch block.Tag {
|
||||
case TagSpacer:
|
||||
pdf.Ln(6)
|
||||
case TagTitle:
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(4)
|
||||
continue
|
||||
}
|
||||
pdf.CellFormat(0, 7, line, "", 1, "C", false, 0, "")
|
||||
}
|
||||
pdf.Ln(2)
|
||||
case TagSubtitle:
|
||||
pdf.SetFont("Helvetica", "", 11)
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(3)
|
||||
continue
|
||||
}
|
||||
pdf.CellFormat(0, 6, line, "", 1, "C", false, 0, "")
|
||||
}
|
||||
pdf.Ln(2)
|
||||
case TagMeta:
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(2)
|
||||
continue
|
||||
}
|
||||
pdf.CellFormat(0, 4.5, line, "", 1, "R", false, 0, "")
|
||||
}
|
||||
pdf.Ln(2)
|
||||
case TagSection:
|
||||
pdf.Ln(2)
|
||||
pdf.SetFont("Helvetica", "B", 11)
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(3)
|
||||
continue
|
||||
}
|
||||
pdf.CellFormat(0, 6, line, "", 1, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(1)
|
||||
case TagText:
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||
pdf.Ln(1)
|
||||
case TagKV:
|
||||
renderKeyValue(pdf, block)
|
||||
case TagTable:
|
||||
renderTable(pdf, block)
|
||||
case TagSign:
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 6, text, "", "L", false)
|
||||
pdf.Ln(2)
|
||||
default:
|
||||
// Unknown tag: treat as plain text for resilience.
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||
pdf.Ln(1)
|
||||
}
|
||||
}
|
||||
|
||||
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
usable := usableWidth(pdf)
|
||||
keyWidth := math.Round(usable * 0.35)
|
||||
valueWidth := usable - keyWidth
|
||||
lineHeight := 5.0
|
||||
|
||||
for _, row := range block.Rows {
|
||||
if len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
key := row[0]
|
||||
value := ""
|
||||
if len(row) > 1 {
|
||||
value = row[1]
|
||||
}
|
||||
x := pdf.GetX()
|
||||
y := pdf.GetY()
|
||||
|
||||
pdf.SetXY(x, y)
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
pdf.MultiCell(keyWidth, lineHeight, key, "", "L", false)
|
||||
leftY := pdf.GetY()
|
||||
|
||||
pdf.SetXY(x+keyWidth, y)
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
pdf.MultiCell(valueWidth, lineHeight, value, "", "L", false)
|
||||
rightY := pdf.GetY()
|
||||
|
||||
pdf.SetY(maxFloat(leftY, rightY))
|
||||
}
|
||||
pdf.Ln(1)
|
||||
}
|
||||
|
||||
func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
if len(block.Rows) == 0 {
|
||||
return
|
||||
}
|
||||
usable := usableWidth(pdf)
|
||||
col1 := math.Round(usable * 0.7)
|
||||
col2 := usable - col1
|
||||
lineHeight := 6.0
|
||||
|
||||
header := block.Rows[0]
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
if len(header) > 0 {
|
||||
pdf.CellFormat(col1, lineHeight, header[0], "1", 0, "L", false, 0, "")
|
||||
}
|
||||
if len(header) > 1 {
|
||||
pdf.CellFormat(col2, lineHeight, header[1], "1", 1, "R", false, 0, "")
|
||||
} else {
|
||||
pdf.CellFormat(col2, lineHeight, "", "1", 1, "R", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
for _, row := range block.Rows[1:] {
|
||||
colA := ""
|
||||
colB := ""
|
||||
if len(row) > 0 {
|
||||
colA = row[0]
|
||||
}
|
||||
if len(row) > 1 {
|
||||
colB = row[1]
|
||||
}
|
||||
x := pdf.GetX()
|
||||
y := pdf.GetY()
|
||||
pdf.MultiCell(col1, lineHeight, colA, "1", "L", false)
|
||||
leftY := pdf.GetY()
|
||||
pdf.SetXY(x+col1, y)
|
||||
pdf.MultiCell(col2, lineHeight, colB, "1", "R", false)
|
||||
rightY := pdf.GetY()
|
||||
pdf.SetY(maxFloat(leftY, rightY))
|
||||
}
|
||||
pdf.Ln(2)
|
||||
}
|
||||
|
||||
func usableWidth(pdf *gofpdf.Fpdf) float64 {
|
||||
pageW, _ := pdf.GetPageSize()
|
||||
left, _, right, _ := pdf.GetMargins()
|
||||
return pageW - left - right
|
||||
}
|
||||
|
||||
func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
90
api/billing/documents/renderer/renderer_test.go
Normal file
90
api/billing/documents/renderer/renderer_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
func TestRenderer_RenderContainsText(t *testing.T) {
|
||||
blocks := []Block{
|
||||
{Tag: TagTitle, Lines: []string{"ACT"}},
|
||||
{Tag: TagText, Lines: []string{"Executor: Jane Doe", "Amount: 100 USD"}},
|
||||
}
|
||||
|
||||
r := Renderer{
|
||||
Issuer: Issuer{
|
||||
LegalName: "Sendico Ltd",
|
||||
LegalAddress: "12 Market Street, London, UK",
|
||||
},
|
||||
OwnerPassword: "",
|
||||
}
|
||||
|
||||
pdfBytes, err := r.Render(blocks, "deadbeef")
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if len(pdfBytes) == 0 {
|
||||
t.Fatalf("expected PDF bytes")
|
||||
}
|
||||
|
||||
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
|
||||
for _, token := range checks {
|
||||
if !containsPDFText(pdfBytes, token) {
|
||||
t.Fatalf("expected PDF to contain %q", token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsPDFText(pdfBytes []byte, text string) bool {
|
||||
if bytes.Contains(pdfBytes, []byte(text)) {
|
||||
return true
|
||||
}
|
||||
hexText := hex.EncodeToString([]byte(text))
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(hexText))) {
|
||||
return true
|
||||
}
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(hexText))) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16Bytes := encodeUTF16BE(text, false)
|
||||
if bytes.Contains(pdfBytes, utf16Bytes) {
|
||||
return true
|
||||
}
|
||||
utf16Hex := hex.EncodeToString(utf16Bytes)
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16Hex))) {
|
||||
return true
|
||||
}
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16Hex))) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16BytesBOM := encodeUTF16BE(text, true)
|
||||
if bytes.Contains(pdfBytes, utf16BytesBOM) {
|
||||
return true
|
||||
}
|
||||
utf16HexBOM := hex.EncodeToString(utf16BytesBOM)
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16HexBOM))) {
|
||||
return true
|
||||
}
|
||||
return bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16HexBOM)))
|
||||
}
|
||||
|
||||
func encodeUTF16BE(text string, withBOM bool) []byte {
|
||||
encoded := utf16.Encode([]rune(text))
|
||||
length := len(encoded) * 2
|
||||
if withBOM {
|
||||
length += 2
|
||||
}
|
||||
out := make([]byte, 0, length)
|
||||
if withBOM {
|
||||
out = append(out, 0xFE, 0xFF)
|
||||
}
|
||||
for _, v := range encoded {
|
||||
out = append(out, byte(v>>8), byte(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
87
api/billing/documents/renderer/tags.go
Normal file
87
api/billing/documents/renderer/tags.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tag defines supported template blocks.
|
||||
type Tag string
|
||||
|
||||
const (
|
||||
TagSpacer Tag = "spacer"
|
||||
TagTitle Tag = "title"
|
||||
TagSubtitle Tag = "subtitle"
|
||||
TagMeta Tag = "meta"
|
||||
TagSection Tag = "section"
|
||||
TagText Tag = "text"
|
||||
TagKV Tag = "kv"
|
||||
TagTable Tag = "table"
|
||||
TagSign Tag = "sign"
|
||||
)
|
||||
|
||||
// Block represents a tagged content block extracted from template output.
|
||||
type Block struct {
|
||||
Tag Tag
|
||||
Lines []string
|
||||
Rows [][]string
|
||||
}
|
||||
|
||||
// ParseBlocks converts tagged template output into structured blocks.
|
||||
func ParseBlocks(input string) ([]Block, error) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
blocks := make([]Block, 0)
|
||||
var current *Block
|
||||
|
||||
flush := func() {
|
||||
if current != nil {
|
||||
blocks = append(blocks, *current)
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
flush()
|
||||
tag := Tag(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if tag == TagSpacer {
|
||||
blocks = append(blocks, Block{Tag: TagSpacer})
|
||||
continue
|
||||
}
|
||||
current = &Block{Tag: tag}
|
||||
continue
|
||||
}
|
||||
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch current.Tag {
|
||||
case TagKV, TagTable:
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, "|")
|
||||
row := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
row = append(row, strings.TrimSpace(part))
|
||||
}
|
||||
current.Rows = append(current.Rows, row)
|
||||
default:
|
||||
current.Lines = append(current.Lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("parse blocks: %w", err)
|
||||
}
|
||||
|
||||
flush()
|
||||
return blocks, nil
|
||||
}
|
||||
Reference in New Issue
Block a user