go/svc/{invoice,speedtest} -> bgpwtf/

Continued from https://gerrit.hackerspace.pl/c/hscloud/+/71 .

Change-Id: I5aef587c7e9a4cec301e3c95530c33914851ad44
diff --git a/bgpwtf/invoice/BUILD.bazel b/bgpwtf/invoice/BUILD.bazel
new file mode 100644
index 0000000..b2e2ee8
--- /dev/null
+++ b/bgpwtf/invoice/BUILD.bazel
@@ -0,0 +1,32 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "calc.go",
+        "main.go",
+        "model.go",
+        "render.go",
+        "statusz.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/invoice",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//go/mirko:go_default_library",
+        "//go/statusz:go_default_library",
+        "//bgpwtf/invoice/templates:go_default_library",
+        "//proto/invoice:go_default_library",
+        "@com_github_golang_glog//:go_default_library",
+        "@com_github_golang_protobuf//proto:go_default_library",
+        "@com_github_mattn_go_sqlite3//:go_default_library",
+        "@com_github_sebastiaanklippert_go_wkhtmltopdf//:go_default_library",
+        "@org_golang_google_grpc//codes:go_default_library",
+        "@org_golang_google_grpc//status:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "invoice",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/invoice/calc.go b/bgpwtf/invoice/calc.go
new file mode 100644
index 0000000..5df763f
--- /dev/null
+++ b/bgpwtf/invoice/calc.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+	"time"
+
+	pb "code.hackerspace.pl/hscloud/proto/invoice"
+)
+
+func calculateInvoiceData(p *pb.Invoice) {
+	p.Unit = p.Data.Unit
+	if p.Unit == "" {
+		p.Unit = "€"
+	}
+
+	p.TotalNet = 0
+	p.Total = 0
+	for _, i := range p.Data.Item {
+		rowTotalNet := uint64(i.UnitPrice * i.Count)
+		rowTotal := uint64(float64(rowTotalNet) * (float64(1) + float64(i.Vat)/100000))
+
+		p.TotalNet += rowTotalNet
+		p.Total += rowTotal
+		i.TotalNet = rowTotalNet
+		i.Total = rowTotal
+	}
+
+	due := int64(time.Hour*24) * p.Data.DaysDue
+	p.DueDate = time.Unix(0, p.Date).Add(time.Duration(due)).UnixNano()
+}
diff --git a/bgpwtf/invoice/main.go b/bgpwtf/invoice/main.go
new file mode 100644
index 0000000..4a80441
--- /dev/null
+++ b/bgpwtf/invoice/main.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+	"context"
+	"flag"
+
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	pb "code.hackerspace.pl/hscloud/proto/invoice"
+)
+
+var (
+	flagDatabasePath string
+	flagInit         bool
+	flagDisablePKI   bool
+)
+
+type service struct {
+	m *model
+}
+
+func (s *service) CreateInvoice(ctx context.Context, req *pb.CreateInvoiceRequest) (*pb.CreateInvoiceResponse, error) {
+	if req.InvoiceData == nil {
+		return nil, status.Error(codes.InvalidArgument, "invoice data must be given")
+	}
+	if len(req.InvoiceData.Item) < 1 {
+		return nil, status.Error(codes.InvalidArgument, "invoice data must contain at least one item")
+	}
+	for i, item := range req.InvoiceData.Item {
+		if item.Title == "" {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice data item %d must have title set", i)
+		}
+		if item.Count == 0 || item.Count > 1000000 {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice data item %d must have correct count", i)
+		}
+		if item.UnitPrice == 0 {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice data item %d must have correct unit price", i)
+		}
+		if item.Vat > 100000 {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice data item %d must have correct vat set", i)
+		}
+	}
+	if len(req.InvoiceData.CustomerBilling) < 1 {
+		return nil, status.Error(codes.InvalidArgument, "invoice data must contain at least one line of the customer's billing address")
+	}
+	if len(req.InvoiceData.InvoicerBilling) < 1 {
+		return nil, status.Error(codes.InvalidArgument, "invoice data must contain at least one line of the invoicer's billing address")
+	}
+	for i, c := range req.InvoiceData.InvoicerContact {
+		if c.Medium == "" {
+			return nil, status.Errorf(codes.InvalidArgument, "contact point %d must have medium set", i)
+		}
+		if c.Contact == "" {
+			return nil, status.Errorf(codes.InvalidArgument, "contact point %d must have contact set", i)
+		}
+	}
+	if req.InvoiceData.InvoicerVatId == "" {
+		return nil, status.Error(codes.InvalidArgument, "invoice data must contain invoicer's vat id")
+	}
+
+	uid, err := s.m.createInvoice(ctx, req.InvoiceData)
+	if err != nil {
+		if _, ok := status.FromError(err); ok {
+			return nil, err
+		}
+		glog.Errorf("createInvoice(_, _): %v", err)
+		return nil, status.Error(codes.Unavailable, "could not create invoice")
+	}
+	return &pb.CreateInvoiceResponse{
+		Uid: uid,
+	}, nil
+}
+
+func (s *service) GetInvoice(ctx context.Context, req *pb.GetInvoiceRequest) (*pb.GetInvoiceResponse, error) {
+	invoice, err := s.m.getInvoice(ctx, req.Uid)
+	if err != nil {
+		if _, ok := status.FromError(err); ok {
+			return nil, err
+		}
+		glog.Errorf("getInvoice(_, %q): %v", req.Uid, err)
+		return nil, status.Error(codes.Unavailable, "internal server error")
+	}
+	res := &pb.GetInvoiceResponse{
+		Invoice: invoice,
+	}
+	return res, nil
+}
+
+func newService(m *model) *service {
+	return &service{
+		m: m,
+	}
+}
+
+func (s *service) invoicePDF(ctx context.Context, uid, language string) ([]byte, error) {
+	sealed, err := s.m.getSealedUid(ctx, uid)
+	if err != nil {
+		return nil, err
+	}
+
+	var rendered []byte
+	if sealed != "" {
+		// Invoice is sealed, return stored PDF.
+		rendered, err = s.m.getRendered(ctx, uid)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		// Invoice is proforma, render.
+		invoice, err := s.m.getInvoice(ctx, uid)
+		if err != nil {
+			return nil, err
+		}
+
+		rendered, err = renderInvoicePDF(invoice, language)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return rendered, nil
+}
+
+func (s *service) RenderInvoice(req *pb.RenderInvoiceRequest, srv pb.Invoicer_RenderInvoiceServer) error {
+	rendered, err := s.invoicePDF(srv.Context(), req.Uid, req.Language)
+	if err != nil {
+		if _, ok := status.FromError(err); ok {
+			return err
+		}
+		glog.Errorf("invoicePDF(_, %q): %v", req.Uid, err)
+		return status.Error(codes.Unavailable, "internal server error")
+	}
+
+	chunkSize := 16 * 1024
+	chunk := &pb.RenderInvoiceResponse{}
+	for i := 0; i < len(rendered); i += chunkSize {
+		if i+chunkSize > len(rendered) {
+			chunk.Data = rendered[i:len(rendered)]
+		} else {
+			chunk.Data = rendered[i : i+chunkSize]
+		}
+		if err := srv.Send(chunk); err != nil {
+			glog.Errorf("srv.Send: %v", err)
+			return status.Error(codes.Unavailable, "stream broken")
+		}
+	}
+	return nil
+}
+
+func (s *service) SealInvoice(ctx context.Context, req *pb.SealInvoiceRequest) (*pb.SealInvoiceResponse, error) {
+	useProformaTime := false
+	if req.DateSource == pb.SealInvoiceRequest_DATE_SOURCE_PROFORMA {
+		useProformaTime = true
+	}
+	if err := s.m.sealInvoice(ctx, req.Uid, req.Language, useProformaTime); err != nil {
+		if _, ok := status.FromError(err); ok {
+			return nil, err
+		}
+		glog.Errorf("sealInvoice(_, %q): %v", req.Uid, err)
+		return nil, status.Error(codes.Unavailable, "internal server error")
+	}
+	return &pb.SealInvoiceResponse{}, nil
+}
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+func main() {
+	flag.StringVar(&flagDatabasePath, "db_path", "./foo.db", "path to sqlite database")
+	flag.BoolVar(&flagInit, "init_db", false, "init database and exit")
+	flag.Parse()
+
+	m, err := newModel(flagDatabasePath)
+	if err != nil {
+		glog.Exitf("newModel: %v", err)
+	}
+	if flagInit {
+		glog.Exit(m.init())
+	}
+
+	mi := mirko.New()
+	if err := mi.Listen(); err != nil {
+		glog.Exitf("Listen failed: %v", err)
+	}
+
+	s := newService(m)
+	s.setupStatusz(mi)
+	pb.RegisterInvoicerServer(mi.GRPC(), s)
+
+	if err := mi.Serve(); err != nil {
+		glog.Exitf("Serve failed: %v", err)
+	}
+
+	<-mi.Done()
+}
diff --git a/bgpwtf/invoice/model.go b/bgpwtf/invoice/model.go
new file mode 100644
index 0000000..d628e1a
--- /dev/null
+++ b/bgpwtf/invoice/model.go
@@ -0,0 +1,295 @@
+package main
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"strconv"
+	"time"
+
+	"github.com/golang/glog"
+	"github.com/golang/protobuf/proto"
+	_ "github.com/mattn/go-sqlite3"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	pb "code.hackerspace.pl/hscloud/proto/invoice"
+)
+
+type model struct {
+	db *sql.DB
+}
+
+func newModel(dsn string) (*model, error) {
+	db, err := sql.Open("sqlite3", dsn)
+	if err != nil {
+		return nil, err
+	}
+	return &model{
+		db: db,
+	}, nil
+}
+
+func (m *model) init() error {
+	_, err := m.db.Exec(`
+		create table invoice (
+			id integer primary key not null,
+			created_time integer not null,
+			proto blob not null
+		);
+		create table invoice_seal (
+			id integer primary key not null,
+			invoice_id integer not null,
+			final_uid text not null unique,
+			sealed_time integer not null,
+			foreign key (invoice_id) references invoice(id)
+		);
+		create table invoice_blob (
+			id integer primary key not null,
+			invoice_id integer not null,
+			pdf blob not null,
+			foreign key (invoice_id) references invoice(id)
+		);
+	`)
+	return err
+}
+
+func (m *model) sealInvoice(ctx context.Context, uid, language string, useProformaTime bool) error {
+	id, err := strconv.Atoi(uid)
+	if err != nil {
+		return status.Error(codes.InvalidArgument, "invalid uid")
+	}
+
+	invoice, err := m.getInvoice(ctx, uid)
+	if err != nil {
+		return err
+	}
+
+	tx, err := m.db.BeginTx(ctx, nil)
+	if err != nil {
+		return err
+	}
+
+	q := `
+		insert into invoice_seal (
+			invoice_id, final_uid, sealed_time
+		) values (
+			?,
+			( select printf("%04d", ifnull( (select final_uid as v from invoice_seal order by final_uid desc limit 1), 19000) + 1 )),
+			?
+		)
+
+	`
+	sealTime := time.Now()
+	if useProformaTime {
+		sealTime = time.Unix(0, invoice.Date)
+	}
+	res, err := tx.Exec(q, id, sealTime.UnixNano())
+	if err != nil {
+		return err
+	}
+
+	lastInvoiceSealId, err := res.LastInsertId()
+	if err != nil {
+		return err
+	}
+
+	q = `
+		select final_uid from invoice_seal where id = ?
+	`
+
+	var finalUid string
+	if err := tx.QueryRow(q, lastInvoiceSealId).Scan(&finalUid); err != nil {
+		return err
+	}
+
+	invoice.State = pb.Invoice_STATE_SEALED
+	// TODO(q3k): this should be configurable.
+	invoice.FinalUid = fmt.Sprintf("FV/%s", finalUid)
+	invoice.Date = sealTime.UnixNano()
+	calculateInvoiceData(invoice)
+
+	pdfBlob, err := renderInvoicePDF(invoice, language)
+	if err != nil {
+		return err
+	}
+
+	q = `
+		insert into invoice_blob (
+			invoice_id, pdf
+		) values (
+			?, ?
+		)
+	`
+
+	if _, err := tx.Exec(q, id, pdfBlob); err != nil {
+		return err
+	}
+
+	if err := tx.Commit(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (m *model) createInvoice(ctx context.Context, id *pb.InvoiceData) (string, error) {
+	data, err := proto.Marshal(id)
+	if err != nil {
+		return "", err
+	}
+
+	sql := `
+		insert into invoice (
+			proto, created_time
+		) values (
+			?, ?
+		)
+	`
+
+	t := time.Now()
+	if id.Date != 0 {
+		t = time.Unix(0, id.Date)
+	}
+
+	res, err := m.db.Exec(sql, data, t.UnixNano())
+	if err != nil {
+		return "", err
+	}
+	uid, err := res.LastInsertId()
+	if err != nil {
+		return "", err
+	}
+
+	glog.Infof("%+v", uid)
+	return fmt.Sprintf("%d", uid), nil
+}
+
+func (m *model) getRendered(ctx context.Context, uid string) ([]byte, error) {
+	id, err := strconv.Atoi(uid)
+	if err != nil {
+		return nil, status.Error(codes.InvalidArgument, "invalid uid")
+	}
+
+	q := `
+		select invoice_blob.pdf from invoice_blob where invoice_blob.invoice_id = ?
+	`
+	res := m.db.QueryRow(q, id)
+
+	data := []byte{}
+	if err := res.Scan(&data); err != nil {
+		if err == sql.ErrNoRows {
+			return nil, status.Error(codes.InvalidArgument, "no such invoice")
+		}
+		return nil, err
+	}
+	return data, nil
+}
+
+func (m *model) getSealedUid(ctx context.Context, uid string) (string, error) {
+	id, err := strconv.Atoi(uid)
+	if err != nil {
+		return "", status.Error(codes.InvalidArgument, "invalid uid")
+	}
+
+	q := `
+		select invoice_seal.final_uid from invoice_seal where invoice_seal.invoice_id = ?
+	`
+	res := m.db.QueryRow(q, id)
+	finalUid := ""
+	if err := res.Scan(&finalUid); err != nil {
+		if err == sql.ErrNoRows {
+			return "", nil
+		}
+		return "", err
+	}
+	return finalUid, nil
+}
+
+type sqlInvoiceSealRow struct {
+	proto       []byte
+	createdTime int64
+	sealedTime  sql.NullInt64
+	finalUid    sql.NullString
+	uid         int64
+}
+
+func (s *sqlInvoiceSealRow) Proto() (*pb.Invoice, error) {
+	data := &pb.InvoiceData{}
+	if err := proto.Unmarshal(s.proto, data); err != nil {
+		return nil, err
+	}
+
+	p := &pb.Invoice{
+		Uid:  fmt.Sprintf("%d", s.uid),
+		Data: data,
+	}
+	if s.finalUid.Valid {
+		p.State = pb.Invoice_STATE_SEALED
+		p.FinalUid = fmt.Sprintf("FV/%s", s.finalUid.String)
+		p.Date = s.sealedTime.Int64
+	} else {
+		p.State = pb.Invoice_STATE_PROFORMA
+		p.FinalUid = fmt.Sprintf("PROFORMA/%d", s.uid)
+		p.Date = s.createdTime
+	}
+	calculateInvoiceData(p)
+	return p, nil
+}
+
+func (m *model) getInvoice(ctx context.Context, uid string) (*pb.Invoice, error) {
+	id, err := strconv.Atoi(uid)
+	if err != nil {
+		return nil, status.Error(codes.InvalidArgument, "invalid uid")
+	}
+
+	q := `
+		select
+				invoice.id, invoice.proto, invoice.created_time, invoice_seal.sealed_time, invoice_seal.final_uid
+		from invoice
+		left join invoice_seal
+		on invoice_seal.invoice_id = invoice.id
+		where invoice.id = ?
+	`
+	res := m.db.QueryRow(q, id)
+	row := sqlInvoiceSealRow{}
+	if err := res.Scan(&row.uid, &row.proto, &row.createdTime, &row.sealedTime, &row.finalUid); err != nil {
+		if err == sql.ErrNoRows {
+			return nil, status.Error(codes.NotFound, "no such invoice")
+		}
+		return nil, err
+	}
+
+	return row.Proto()
+}
+
+func (m *model) getInvoices(ctx context.Context) ([]*pb.Invoice, error) {
+	q := `
+		select
+				invoice.id, invoice.proto, invoice.created_time, invoice_seal.sealed_time, invoice_seal.final_uid
+		from invoice
+		left join invoice_seal
+		on invoice_seal.invoice_id = invoice.id
+	`
+	rows, err := m.db.QueryContext(ctx, q)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	res := []*pb.Invoice{}
+
+	for rows.Next() {
+		row := sqlInvoiceSealRow{}
+		if err := rows.Scan(&row.uid, &row.proto, &row.createdTime, &row.sealedTime, &row.finalUid); err != nil {
+			return nil, err
+		}
+		p, err := row.Proto()
+		if err != nil {
+			return nil, err
+		}
+		res = append(res, p)
+	}
+
+	return res, nil
+}
diff --git a/bgpwtf/invoice/proto/BUILD.bazel b/bgpwtf/invoice/proto/BUILD.bazel
new file mode 100644
index 0000000..511bf26
--- /dev/null
+++ b/bgpwtf/invoice/proto/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["generate.go"],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto",
+    visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/invoice/proto/generate.go b/bgpwtf/invoice/proto/generate.go
new file mode 100644
index 0000000..b0f6618
--- /dev/null
+++ b/bgpwtf/invoice/proto/generate.go
@@ -0,0 +1,3 @@
+package proto
+
+//go:generate protoc -I.. ../inboice.proto --go_out=plugins=grpc:.
diff --git a/bgpwtf/invoice/render.go b/bgpwtf/invoice/render.go
new file mode 100644
index 0000000..2353014
--- /dev/null
+++ b/bgpwtf/invoice/render.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+	"time"
+
+	wkhtml "github.com/sebastiaanklippert/go-wkhtmltopdf"
+
+	"code.hackerspace.pl/hscloud/bgpwtf/invoice/templates"
+	pb "code.hackerspace.pl/hscloud/proto/invoice"
+)
+
+var (
+	invTmpl map[string]*template.Template
+
+	languages       = []string{"en", "pl"}
+	defaultLanguage = "en"
+)
+
+func init() {
+	invTmpl = make(map[string]*template.Template)
+	for _, language := range languages {
+		filename := fmt.Sprintf("invoice_%s.html", language)
+		a, err := templates.Asset(filename)
+		if err != nil {
+			panic(err)
+		}
+		invTmpl[language] = template.Must(template.New(filename).Parse(string(a)))
+	}
+}
+
+func renderInvoicePDF(i *pb.Invoice, language string) ([]byte, error) {
+	type item struct {
+		Title     string
+		UnitPrice string
+		Qty       string
+		VATRate   string
+		TotalNet  string
+		Total     string
+	}
+
+	data := struct {
+		InvoiceNumber         string
+		InvoicerBilling       []string
+		InvoicerVAT           string
+		InvoicerCompanyNumber string
+		InvoiceeBilling       []string
+		InvoiceeVAT           string
+		Date                  time.Time
+		DueDate               time.Time
+		IBAN                  string
+		SWIFT                 string
+		Proforma              bool
+		ReverseVAT            bool
+		USCustomer            bool
+		Items                 []item
+		TotalNet              string
+		VATTotal              string
+		Total                 string
+		DeliveryCharge        string
+	}{
+		InvoiceNumber:         i.FinalUid,
+		Date:                  time.Unix(0, i.Date),
+		DueDate:               time.Unix(0, i.DueDate),
+		IBAN:                  i.Data.Iban,
+		SWIFT:                 i.Data.Swift,
+		InvoicerVAT:           i.Data.InvoicerVatId,
+		InvoicerCompanyNumber: i.Data.InvoicerCompanyNumber,
+		InvoiceeVAT:           i.Data.CustomerVatId,
+		Proforma:              i.State == pb.Invoice_STATE_PROFORMA,
+		ReverseVAT:            i.Data.ReverseVat,
+		USCustomer:            i.Data.UsCustomer,
+
+		InvoicerBilling: make([]string, len(i.Data.InvoicerBilling)),
+		InvoiceeBilling: make([]string, len(i.Data.CustomerBilling)),
+	}
+
+	for d, s := range i.Data.InvoicerBilling {
+		data.InvoicerBilling[d] = s
+	}
+	for d, s := range i.Data.CustomerBilling {
+		data.InvoiceeBilling[d] = s
+	}
+
+	unit := i.Unit
+
+	for _, it := range i.Data.Item {
+		data.Items = append(data.Items, item{
+			Title:     it.Title,
+			Qty:       fmt.Sprintf("%d", it.Count),
+			UnitPrice: fmt.Sprintf(unit+"%.2f", float64(it.UnitPrice)/100),
+			VATRate:   fmt.Sprintf("%.2f%%", float64(it.Vat)/1000),
+			TotalNet:  fmt.Sprintf(unit+"%.2f", float64(it.TotalNet)/100),
+			Total:     fmt.Sprintf(unit+"%.2f", float64(it.Total)/100),
+		})
+	}
+
+	data.TotalNet = fmt.Sprintf(unit+"%.2f", float64(i.TotalNet)/100)
+	data.VATTotal = fmt.Sprintf(unit+"%.2f", float64(i.Total-i.TotalNet)/100)
+	data.Total = fmt.Sprintf(unit+"%.2f", float64(i.Total)/100)
+	data.DeliveryCharge = fmt.Sprintf(unit+"%.2f", float64(0))
+
+	if _, ok := invTmpl[language]; !ok {
+		language = defaultLanguage
+	}
+
+	var b bytes.Buffer
+	err := invTmpl[language].Execute(&b, &data)
+	if err != nil {
+		return []byte{}, err
+	}
+
+	pdfg, err := wkhtml.NewPDFGenerator()
+	if err != nil {
+		return []byte{}, err
+	}
+	pdfg.Dpi.Set(600)
+	pdfg.NoCollate.Set(false)
+	pdfg.PageSize.Set(wkhtml.PageSizeA4)
+
+	pdfg.AddPage(wkhtml.NewPageReader(&b))
+
+	if err := pdfg.Create(); err != nil {
+		return []byte{}, err
+	}
+	return pdfg.Bytes(), nil
+}
diff --git a/bgpwtf/invoice/statusz.go b/bgpwtf/invoice/statusz.go
new file mode 100644
index 0000000..0a64ce4
--- /dev/null
+++ b/bgpwtf/invoice/statusz.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"sort"
+	"time"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"code.hackerspace.pl/hscloud/go/statusz"
+	pb "code.hackerspace.pl/hscloud/proto/invoice"
+	"github.com/golang/glog"
+)
+
+const invoicesFragment = `
+    <style type="text/css">
+		.table td,th {
+			background-color: #eee;
+			padding: 0.2em 0.4em 0.2em 0.4em;
+		}
+		.table th {
+			background-color: #c0c0c0;
+		}
+		.table {
+			background-color: #fff;
+			border-spacing: 0.2em;
+			margin-left: auto;
+			margin-right: auto;
+		}
+	</style>
+    <div>
+		{{ .Msg }}
+        <table class="table">
+            <tr>
+                <th>Number</th>
+				<th>Created/Issued</th>
+				<th>Customer</th>
+				<th>Amount (net)</th>
+				<th>Actions</th>
+            </tr>
+            {{ range .Invoices }}
+				{{ if eq .State 2 }}
+				<tr>
+				{{ else }}
+				<tr style="opacity: 0.5">
+				{{ end }}
+				    <td>{{ .FinalUid }}</td>
+					<td>{{ .DatePretty.Format "2006/01/02 15:04:05" }}</td>
+					<td>{{ index .Data.CustomerBilling 0 }}</td>
+					<td>{{ .TotalNetPretty }}</td>
+				    <td>
+						{{ if eq .State 2 }}
+						<a href="/debug/view?id={{ .Uid }}">View</a>
+						{{ else }}
+						<a href="/debug/view?id={{ .Uid }}&language=en">Preview (en)</a> |
+						<a href="/debug/view?id={{ .Uid }}&language=pl">Preview (pl)</a>
+						{{ end }}
+					</td>
+				</tr>
+			{{ end }}
+        </table>
+    </div>
+`
+
+type templateInvoice struct {
+	*pb.Invoice
+	TotalNetPretty string
+	DatePretty     time.Time
+}
+
+func (s *service) setupStatusz(m *mirko.Mirko) {
+	statusz.AddStatusPart("Invoices", invoicesFragment, func(ctx context.Context) interface{} {
+		var res struct {
+			Invoices []templateInvoice
+			Msg      string
+		}
+		invoices, err := s.m.getInvoices(ctx)
+		res.Invoices = make([]templateInvoice, len(invoices))
+		for i, inv := range invoices {
+			res.Invoices[i] = templateInvoice{
+				Invoice:        inv,
+				TotalNetPretty: fmt.Sprintf("%.2f %s", float64(inv.TotalNet)/100, inv.Unit),
+				DatePretty:     time.Unix(0, inv.Date),
+			}
+		}
+
+		if err != nil {
+			glog.Errorf("Could not get invoices for statusz: %v", err)
+			res.Msg = fmt.Sprintf("Could not get invoices: %v", err)
+		}
+
+		sort.Slice(res.Invoices, func(i, j int) bool { return res.Invoices[i].Date > res.Invoices[j].Date })
+		return res
+	})
+
+	m.HTTPMux().HandleFunc("/debug/view", func(w http.ResponseWriter, r *http.Request) {
+		rendered, err := s.invoicePDF(r.Context(), r.URL.Query().Get("id"), r.URL.Query().Get("language"))
+		if err != nil {
+			fmt.Fprintf(w, "error: %v", err)
+		}
+		w.Header().Set("Content-type", "application/pdf")
+		w.Write(rendered)
+	})
+}
diff --git a/bgpwtf/invoice/templates/BUILD.bazel b/bgpwtf/invoice/templates/BUILD.bazel
new file mode 100644
index 0000000..4756da4
--- /dev/null
+++ b/bgpwtf/invoice/templates/BUILD.bazel
@@ -0,0 +1,18 @@
+load("@io_bazel_rules_go//extras:bindata.bzl", "bindata")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+bindata(
+    name = "templates_bindata",
+    srcs = glob(["*"]),
+    extra_args = ["."],
+    package = "templates",
+)
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        ":templates_bindata",  # keep
+    ],
+    importpath = "code.hackerspace.pl/hscloud/bgpwtf/invoice/templates",  # keep
+    visibility = ["//bgpwtf/invoice:__subpackages__"],
+)
diff --git a/bgpwtf/invoice/templates/invoice_en.html b/bgpwtf/invoice/templates/invoice_en.html
new file mode 100644
index 0000000..d661732
--- /dev/null
+++ b/bgpwtf/invoice/templates/invoice_en.html
@@ -0,0 +1,199 @@
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8">
+        <title>Invoice 0001</title>
+        <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,700" rel="stylesheet">
+        <style type="text/css">
+body {
+    background-color: #fff;
+    font-family: 'Roboto', sans-serif;
+    font-size: 1em;
+
+    padding: 2em;
+}
+ul {
+    list-style: none;
+    padding: 0;
+}
+ul li {
+    margin-bottom: 0.2em;
+}
+
+@page {
+  size: A4;
+  margin: 0;
+}
+div.rhs {
+    float: right;
+    width: 50%;
+    text-align: right;
+}
+div.lhs {
+    float: left;
+    text-align: left;
+    width: 50%;
+    min-height: 35em;
+}
+div.metadata {
+    margin-top: 2em;
+}
+div.invoicee {
+    margin-top: 9em;
+}
+h1 {
+    font-size: 1.5em;
+    margin: 0;
+    text-transform: uppercase;
+}
+h2 {
+    font-size: 1.2em;
+    margin: 0;
+}
+table.items {
+    text-align: right;
+    border-spacing: 0px;
+    border-collapse: collapse;
+    border: 0;
+    width: 100%;
+}
+table.items td,th {
+    border: 1px solid black;
+}
+table.items tr:first-child {
+    background-color: #eee;
+    color: #111;
+    padding: 0.8em;
+    text-align: left;
+}
+table.items td {
+    background-color: #fff;
+}
+table.items td,th {
+    padding: 0.5em 1em 0.5em 1em;
+}
+td.lhead {
+    border: 0 !important;
+    text-align: right;
+    text-transform: uppercase;
+    background: rgba(0, 0, 0, 0) !important;
+}
+div.bgtext {
+    z-index: -10;
+    position: absolute;
+    top: 140mm;
+    left: 0;
+    width: 100%;
+}
+div.bgtext div {
+    text-align: center;
+    font-size: 10em;
+    color: #ddd;
+    -webkit-transform: rotate(-45deg);
+    text-transform: uppercase;
+}
+        </style>
+    </head>
+    <body>
+        {{ if .Proforma }}
+        <div class="bgtext"><div>Proforma</div></div>
+        {{ end }}
+        <div class="rhs">
+            <div class="invoicer">
+                <ul>
+                    {{ range $i, $e := .InvoicerBilling }}
+                    {{ if eq $i 0 }}
+                    <li><b>{{ $e }}</b></li>
+                    {{ else }}
+                    <li>{{ $e }}</li>
+                    {{ end }}
+                    {{ end }}
+                    {{ if .InvoicerCompanyNumber }}
+                    <li>{{ .InvoicerCompanyNumber }}</li>
+                    {{ end }}
+                    <li><b>Tax Number:</b> {{ .InvoicerVAT }}</li>
+                </ul>
+            </div>
+            <div class="metadata">
+                <ul>
+                    <li><b>Invoice number:</b> {{ .InvoiceNumber }}</li>
+                    <li><b>Date:</b> {{ .Date.Format "2006/01/02" }}</li>
+                    <li><b>Due date:</b> {{ .DueDate.Format "2006/01/02" }}</li>
+                    <li><b>IBAN:</b> {{ .IBAN }}</li>
+                    <li><b>SWIFT/BIC:</b> {{ .SWIFT }}</li>
+                </ul>
+            </div>
+        </div>
+        <div class="lhs">
+            <div class="invoicee">
+                {{ if .Proforma }}
+                <h1>Proforma Invoice</h1>
+                {{ else }}
+                <h1>VAT Invoice</h1>
+                {{ end }}
+                <ul>
+                    {{ range $i, $e := .InvoiceeBilling }}
+                    {{ if eq $i 0 }}
+                    <li><b>{{ $e }}</b></li>
+                    {{ else }}
+                    <li>{{ $e }}</li>
+                    {{ end }}
+                    {{ end }}
+                    {{ if .USCustomer }}
+                    <li>EIN: {{ .InvoiceeVAT }}</li>
+                    <li><b>(VAT zero rate)</b></li>
+                    {{ else }}
+                    <li><b>NIP:</b> {{ .InvoiceeVAT }}</li>
+                    {{ end }}
+
+                    {{ if .ReverseVAT }}
+                    <li><b>(reverse charge / obciążenie odwrotne)</b></li>
+                    {{ end }}
+                </ul>
+            </div>
+        </div>
+        <div style="clear: both; height: 1em;"></div>
+        <table class="items">
+            <tr>
+                <th style="width: 60%;">Name of goods / service</th>
+                <th>Unit price<br />(net)</th>
+                <th>Qty</th>
+                {{ if not .ReverseVAT }}
+                <th>VAT rate</th>
+                {{ end }}
+                <th>Goods value<br />(net)</th>
+                {{ if not .ReverseVAT }}
+                <th>Goods value<br />(gross)</th>
+                {{ end }}
+            </tr>
+            {{ range .Items }}
+            <tr>
+                <td style="text-align: left;">{{ .Title }}</td>
+                <td>{{ .UnitPrice }}</td>
+                <td>{{ .Qty }}</td>
+                {{ if not $.ReverseVAT }}
+                <td>{{ .VATRate }}</td>
+                {{ end }}
+                <td>{{ .TotalNet }}</td>
+                {{ if not $.ReverseVAT }}
+                <td>{{ .Total }}</td>
+                {{ end }}
+            </tr>
+            {{ end }}
+            {{ if not .ReverseVAT }}
+            <tr>
+                <td colspan="5" class="lhead">Subtotal without VAT</td>
+                <td>{{ .TotalNet }}</td>
+            </tr>
+            <tr>
+                <td colspan="5" class="lhead">VAT Total {{ if .USCustomer }}(VAT zero rate){{ end }}</td>
+                <td>{{ .VATTotal }}</td>
+            </tr>
+            {{ end }}
+            <tr>
+                <td colspan="{{ if .ReverseVAT }}3{{ else }}5{{ end }}" class="lhead"><b>Total</b></td>
+                <td><b>{{ .Total }}</b></td>
+            </tr>
+        </table>
+    </body>
+</html>
diff --git a/bgpwtf/invoice/templates/invoice_pl.html b/bgpwtf/invoice/templates/invoice_pl.html
new file mode 100644
index 0000000..df49da8
--- /dev/null
+++ b/bgpwtf/invoice/templates/invoice_pl.html
@@ -0,0 +1,190 @@
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8">
+        <title>Invoice 0001</title>
+        <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,700" rel="stylesheet">
+        <style type="text/css">
+body {
+    background-color: #fff;
+    font-family: 'Roboto', sans-serif;
+    font-size: 1em;
+
+    padding: 2em;
+}
+ul {
+    list-style: none;
+    padding: 0;
+}
+ul li {
+    margin-bottom: 0.2em;
+}
+
+@page {
+  size: A4;
+  margin: 0;
+}
+div.rhs {
+    float: right;
+    width: 50%;
+    text-align: right;
+}
+div.lhs {
+    float: left;
+    text-align: left;
+    width: 50%;
+    min-height: 35em;
+}
+div.metadata {
+    margin-top: 2em;
+}
+div.invoicee {
+    margin-top: 9em;
+}
+h1 {
+    font-size: 1.5em;
+    margin: 0;
+    text-transform: uppercase;
+}
+h2 {
+    font-size: 1.2em;
+    margin: 0;
+}
+table.items {
+    text-align: right;
+    border-spacing: 0px;
+    border-collapse: collapse;
+    border: 0;
+    width: 100%;
+}
+table.items td,th {
+    border: 1px solid black;
+}
+table.items tr:first-child {
+    background-color: #eee;
+    color: #111;
+    padding: 0.8em;
+    text-align: left;
+}
+table.items td {
+    background-color: #fff;
+}
+table.items td,th {
+    padding: 0.5em 1em 0.5em 1em;
+}
+td.lhead {
+    border: 0 !important;
+    text-align: right;
+    text-transform: uppercase;
+    background: rgba(0, 0, 0, 0) !important;
+}
+div.bgtext {
+    z-index: -10;
+    position: absolute;
+    top: 140mm;
+    left: 0;
+    width: 100%;
+}
+div.bgtext div {
+    text-align: center;
+    font-size: 10em;
+    color: #ddd;
+    -webkit-transform: rotate(-45deg);
+    text-transform: uppercase;
+}
+        </style>
+    </head>
+    <body>
+        {{ if .Proforma }}
+        <div class="bgtext"><div>Proforma</div></div>
+        {{ end }}
+        <div class="rhs">
+            <div class="invoicer">
+                <ul>
+                    {{ range $i, $e := .InvoicerBilling }}
+                    {{ if eq $i 0 }}
+                    <li><b>{{ $e }}</b></li>
+                    {{ else }}
+                    <li>{{ $e }}</li>
+                    {{ end }}
+                    {{ end }}
+                    {{ if .InvoicerCompanyNumber }}
+                    <li>{{ .InvoicerCompanyNumber }}</li>
+                    {{ end }}
+                    <li><b>NIP:</b> {{ .InvoicerVAT }}</li>
+                </ul>
+            </div>
+            <div class="metadata">
+                <ul>
+                    <li><b>Numer faktury:</b> {{ .InvoiceNumber }}</li>
+                    <li><b>Data wystawienia:</b> {{ .Date.Format "2006/01/02" }}</li>
+                    <li><b>Termin płatności:</b> {{ .DueDate.Format "2006/01/02" }}</li>
+                    <li><b>IBAN:</b> {{ .IBAN }}</li>
+                    <li><b>SWIFT/BIC:</b> {{ .SWIFT }}</li>
+                </ul>
+            </div>
+        </div>
+        <div class="lhs">
+            <div class="invoicee">
+                {{ if .Proforma }}
+                <h1>Faktura Proforma</h1>
+                {{ else }}
+                <h1>Faktura VAT</h1>
+                {{ end }}
+                <h2>nr. {{ .InvoiceNumber }}</h2>
+                <ul>
+                    {{ range $i, $e := .InvoiceeBilling }}
+                    {{ if eq $i 0 }}
+                    <li><b>{{ $e }}</b></li>
+                    {{ else }}
+                    <li>{{ $e }}</li>
+                    {{ end }}
+                    {{ end }}
+                    {{ if .USCustomer }}
+                    <li>EIN: {{ .InvoiceeVAT }}</li>
+                    <li><b>(VAT zero rate)</b></li>
+                    {{ else }}
+                    <li><b>NIP:</b> {{ .InvoiceeVAT }}</li>
+                    {{ end }}
+
+                    {{ if .ReverseVAT }}
+                    <li><b>(nie podlega VAT)</b></li>
+                    {{ end }}
+                </ul>
+            </div>
+        </div>
+        <div style="clear: both; height: 1em;"></div>
+        <table class="items">
+            <tr>
+                <th style="width: 60%;">Nazwa towaru lub usługi</th>
+                <th>Cena<br />netto</th>
+                <th>Ilość</th>
+                <th>VAT (%)</th>
+                <th>Wartość<br />netto</th>
+                <th>Wartość<br />brutto</th>
+            </tr>
+            {{ range .Items }}
+            <tr>
+                <td style="text-align: left;">{{ .Title }}</td>
+                <td>{{ .UnitPrice }}</td>
+                <td>{{ .Qty }}</td>
+                <td>{{ .VATRate }}</td>
+                <td>{{ .TotalNet }}</td>
+                <td>{{ .Total }}</td>
+            </tr>
+            {{ end }}
+            <tr>
+                <td colspan="5" class="lhead">RAZEM netto</td>
+                <td>{{ .TotalNet }}</td>
+            </tr>
+            <tr>
+                <td colspan="5" class="lhead">VAT{{ if .ReverseVAT }} (nie podlega){{ end }} {{ if .USCustomer }}(nie podlega){{ end }}</td>
+                <td>{{ .VATTotal }}</td>
+            </tr>
+            <tr>
+                <td colspan="5" class="lhead"><b>RAZEM brutto</b></td>
+                <td><b>{{ .Total }}</b></td>
+            </tr>
+        </table>
+    </body>
+</html>