go/svc/invoice: import from code.hackerspace.pl/q3k/inboice
diff --git a/go/svc/invoice/BUILD.bazel b/go/svc/invoice/BUILD.bazel
new file mode 100644
index 0000000..d654bc9
--- /dev/null
+++ b/go/svc/invoice/BUILD.bazel
@@ -0,0 +1,29 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "main.go",
+        "model.go",
+        "render.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/go/svc/invoice",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//go/mirko:go_default_library",
+        "//go/svc/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/go/svc/invoice/main.go b/go/svc/invoice/main.go
new file mode 100644
index 0000000..8a5ddbe
--- /dev/null
+++ b/go/svc/invoice/main.go
@@ -0,0 +1,211 @@
+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.Invoice == nil {
+		return nil, status.Error(codes.InvalidArgument, "invoice must be given")
+	}
+	if len(req.Invoice.Item) < 1 {
+		return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one item")
+	}
+	for i, item := range req.Invoice.Item {
+		if item.Title == "" {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice item %d must have title set", i)
+		}
+		if item.Count == 0 || item.Count > 1000000 {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice item %d must have correct count", i)
+		}
+		if item.UnitPrice == 0 {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice item %d must have correct unit price", i)
+		}
+		if item.Vat > 100000 {
+			return nil, status.Errorf(codes.InvalidArgument, "invoice item %d must have correct vat set", i)
+		}
+	}
+	if len(req.Invoice.CustomerBilling) < 1 {
+		return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one line of the customer's billing address")
+	}
+	if len(req.Invoice.InvoicerBilling) < 1 {
+		return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one line of the invoicer's billing address")
+	}
+	for i, c := range req.Invoice.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.Invoice.InvoicerVatId == "" {
+		return nil, status.Error(codes.InvalidArgument, "invoice must contain invoicer's vat id")
+	}
+
+	uid, err := s.m.createInvoice(ctx, req.Invoice)
+	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")
+	}
+	sealedUid, err := s.m.getSealedUid(ctx, req.Uid)
+	if err != nil {
+		if _, ok := status.FromError(err); ok {
+			return nil, err
+		}
+		glog.Errorf("getSealedUid(_, %q): %v", req.Uid, err)
+		return nil, status.Error(codes.Unavailable, "internal server error")
+	}
+
+	res := &pb.GetInvoiceResponse{
+		Invoice: invoice,
+	}
+	if sealedUid == "" {
+		res.State = pb.GetInvoiceResponse_STATE_PROFORMA
+	} else {
+		res.State = pb.GetInvoiceResponse_STATE_SEALED
+		res.FinalUid = sealedUid
+	}
+
+	return res, nil
+}
+
+func newService(m *model) *service {
+	return &service{
+		m: m,
+	}
+}
+
+func (s *service) RenderInvoice(req *pb.RenderInvoiceRequest, srv pb.Invoicer_RenderInvoiceServer) error {
+	sealed, err := s.m.getSealedUid(srv.Context(), req.Uid)
+	if err != nil {
+		if _, ok := status.FromError(err); ok {
+			return err
+		}
+		glog.Errorf("getSealedUid(_, %q): %v", req.Uid, err)
+		return status.Error(codes.Unavailable, "internal server error")
+	}
+
+	var rendered []byte
+	if sealed != "" {
+		// Invoice is sealed, return stored PDF.
+		rendered, err = s.m.getRendered(srv.Context(), req.Uid)
+		if err != nil {
+			if _, ok := status.FromError(err); ok {
+				return err
+			}
+			glog.Errorf("getRendered(_, %q): %v", req.Uid, err)
+			return status.Error(codes.Unavailable, "internal server error")
+		}
+	} else {
+		// Invoice is proforma, render.
+		invoice, err := s.m.getInvoice(srv.Context(), req.Uid)
+		if err != nil {
+			if _, ok := status.FromError(err); ok {
+				return err
+			}
+			glog.Errorf("getInvoice(_, %q): %v", req.Uid, err)
+			return status.Error(codes.Unavailable, "internal server error")
+		}
+
+		glog.Infof("%+v", invoice)
+		rendered, err = renderInvoicePDF(invoice, "xxxx", true)
+		if err != nil {
+			glog.Errorf("renderProformaPDF(_): %v", 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) {
+	if err := s.m.sealInvoice(ctx, req.Uid); 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)
+	pb.RegisterInvoicerServer(mi.GRPC(), s)
+
+	if err := mi.Serve(); err != nil {
+		glog.Exitf("Serve failed: %v", err)
+	}
+
+	<-mi.Done()
+}
diff --git a/go/svc/invoice/model.go b/go/svc/invoice/model.go
new file mode 100644
index 0000000..0ed8245
--- /dev/null
+++ b/go/svc/invoice/model.go
@@ -0,0 +1,213 @@
+package main
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"strconv"
+
+	"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,
+			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,
+			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 string) 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
+		) values (
+			?,
+			( select printf("%04d", ifnull( (select final_uid as v from invoice_seal order by final_uid desc limit 1), 19000) + 1 ))
+		)
+
+	`
+	res, err := tx.Exec(q, id)
+	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
+	}
+
+	q = `
+		insert into invoice_blob (
+			invoice_id, pdf
+		) values (
+			?,
+			?
+		)
+	`
+
+	pdfBlob, err := renderInvoicePDF(invoice, finalUid, false)
+	if err != nil {
+		return err
+	}
+
+	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, i *pb.Invoice) (string, error) {
+	data, err := proto.Marshal(i)
+	if err != nil {
+		return "", err
+	}
+
+	sql := `
+		insert into invoice (
+			proto
+		) values (
+			?
+		)
+	`
+	res, err := m.db.Exec(sql, data)
+	if err != nil {
+		return "", err
+	}
+	id, err := res.LastInsertId()
+	if err != nil {
+		return "", err
+	}
+
+	glog.Infof("%+v", id)
+	return fmt.Sprintf("%d", id), 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
+}
+
+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.proto from invoice where 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.NotFound, "no such invoice")
+		}
+		return nil, err
+	}
+
+	p := &pb.Invoice{}
+	if err := proto.Unmarshal(data, p); err != nil {
+		return nil, err
+	}
+	return p, nil
+}
diff --git a/go/svc/invoice/proto/BUILD.bazel b/go/svc/invoice/proto/BUILD.bazel
new file mode 100644
index 0000000..63c82cc
--- /dev/null
+++ b/go/svc/invoice/proto/BUILD.bazel
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = [
+        "generate.go",
+        "inboice.pb.go",
+    ],
+    importpath = "code.hackerspace.pl/hscloud/go/svc/invoice/proto",
+    visibility = ["//visibility:public"],
+    deps = [
+        "@com_github_golang_protobuf//proto:go_default_library",
+        "@org_golang_google_grpc//:go_default_library",
+        "@org_golang_x_net//context:go_default_library",
+    ],
+)
diff --git a/go/svc/invoice/proto/generate.go b/go/svc/invoice/proto/generate.go
new file mode 100644
index 0000000..b0f6618
--- /dev/null
+++ b/go/svc/invoice/proto/generate.go
@@ -0,0 +1,3 @@
+package proto
+
+//go:generate protoc -I.. ../inboice.proto --go_out=plugins=grpc:.
diff --git a/go/svc/invoice/render.go b/go/svc/invoice/render.go
new file mode 100644
index 0000000..47c6c6d
--- /dev/null
+++ b/go/svc/invoice/render.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+	"time"
+
+	wkhtml "github.com/sebastiaanklippert/go-wkhtmltopdf"
+
+	"code.hackerspace.pl/hscloud/go/svc/invoice/templates"
+	pb "code.hackerspace.pl/hscloud/proto/invoice"
+)
+
+var (
+	invTmpl *template.Template
+)
+
+func init() {
+	a, err := templates.Asset("invoice.html")
+	if err != nil {
+		panic(err)
+	}
+	invTmpl = template.Must(template.New("invoice.html").Parse(string(a)))
+}
+
+func renderInvoicePDF(i *pb.Invoice, number string, proforma bool) ([]byte, error) {
+	now := time.Now()
+
+	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:         number,
+		Date:                  now,
+		DueDate:               now.AddDate(0, 0, int(i.DaysDue)),
+		IBAN:                  i.Iban,
+		SWIFT:                 i.Swift,
+		InvoicerVAT:           i.InvoicerVatId,
+		InvoicerCompanyNumber: i.InvoicerCompanyNumber,
+		InvoiceeVAT:           i.CustomerVatId,
+		Proforma:              proforma,
+		ReverseVAT:            i.ReverseVat,
+		USCustomer:            i.UsCustomer,
+
+		InvoicerBilling: make([]string, len(i.InvoicerBilling)),
+		InvoiceeBilling: make([]string, len(i.CustomerBilling)),
+	}
+
+	unit := "€"
+
+	for d, s := range i.InvoicerBilling {
+		data.InvoicerBilling[d] = s
+	}
+	for d, s := range i.CustomerBilling {
+		data.InvoiceeBilling[d] = s
+	}
+
+	totalNet := 0
+	total := 0
+	for _, i := range i.Item {
+		rowTotalNet := int(i.UnitPrice * i.Count)
+		rowTotal := int(float64(rowTotalNet) * (float64(1) + float64(i.Vat)/100000))
+
+		totalNet += rowTotalNet
+		total += rowTotal
+		data.Items = append(data.Items, item{
+			Title:     i.Title,
+			Qty:       fmt.Sprintf("%d", i.Count),
+			UnitPrice: fmt.Sprintf(unit+"%.2f", float64(i.UnitPrice)/100),
+			VATRate:   fmt.Sprintf("%.2f%%", float64(i.Vat)/1000),
+			TotalNet:  fmt.Sprintf(unit+"%.2f", float64(rowTotalNet)/100),
+			Total:     fmt.Sprintf(unit+"%.2f", float64(rowTotal)/100),
+		})
+	}
+
+	data.TotalNet = fmt.Sprintf(unit+"%.2f", float64(totalNet)/100)
+	data.VATTotal = fmt.Sprintf(unit+"%.2f", float64(total-totalNet)/100)
+	data.Total = fmt.Sprintf(unit+"%.2f", float64(total)/100)
+	data.DeliveryCharge = fmt.Sprintf(unit+"%.2f", float64(0))
+
+	var b bytes.Buffer
+	err := invTmpl.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/go/svc/invoice/templates/BUILD.bazel b/go/svc/invoice/templates/BUILD.bazel
new file mode 100644
index 0000000..7874687
--- /dev/null
+++ b/go/svc/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/go/svc/invoice/templates",  # keep
+    visibility = ["//go/svc/invoice:__subpackages__"],
+)
diff --git a/go/svc/invoice/templates/invoice.html b/go/svc/invoice/templates/invoice.html
new file mode 100644
index 0000000..e257a42
--- /dev/null
+++ b/go/svc/invoice/templates/invoice.html
@@ -0,0 +1,185 @@
+<!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;
+}
+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><b>Company Registration Number:</b> {{ .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>VAT Number: {{ .InvoiceeVAT }}</li>
+                    {{ end }}
+
+                    {{ if .ReverseVAT }}
+                    <li><b>(reverse charge applies)</b></li>
+                    {{ end }}
+                </ul>
+            </div>
+        </div>
+        <div style="clear: both; height: 1em;"></div>
+        <table class="items">
+            <tr>
+                <th style="width: 60%;">Description</th>
+                <th>Price<br/>(ex. VAT)</th>
+                <th>Qty</th>
+                <th>VAT rate</th>
+                <th>Total<br />(net)</th>
+                <th>Total<br />(inc. VAT)</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">Subtotal without VAT</td>
+                <td>{{ .TotalNet }}</td>
+            </tr>
+            <tr>
+                <td colspan="5" class="lhead">VAT Total{{ if .ReverseVAT }} (reverse charge applies){{ end }} {{ if .USCustomer }}(VAT zero rate){{ end }}</td>
+                <td>{{ .VATTotal }}</td>
+            </tr>
+            <tr>
+                <td colspan="5" class="lhead"><b>Total</b></td>
+                <td>{{ .Total }}</td>
+            </tr>
+        </table>
+    </body>
+</html>
diff --git a/proto/invoice/BUILD.bazel b/proto/invoice/BUILD.bazel
new file mode 100644
index 0000000..0108f44
--- /dev/null
+++ b/proto/invoice/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+    name = "invoice_proto",
+    srcs = ["invoice.proto"],
+    visibility = ["//visibility:public"],
+)
+
+go_proto_library(
+    name = "invoice_go_proto",
+    compilers = ["@io_bazel_rules_go//proto:go_grpc"],
+    importpath = "code.hackerspace.pl/hscloud/proto/invoice",
+    proto = ":invoice_proto",
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "go_default_library",
+    embed = [":invoice_go_proto"],
+    importpath = "code.hackerspace.pl/hscloud/proto/invoice",
+    visibility = ["//visibility:public"],
+)
diff --git a/proto/invoice/invoice.proto b/proto/invoice/invoice.proto
new file mode 100644
index 0000000..e56253d
--- /dev/null
+++ b/proto/invoice/invoice.proto
@@ -0,0 +1,78 @@
+syntax = "proto3";
+
+package invoice;
+
+message Item {
+    string title = 1;
+    uint64 count = 2;
+    uint64 unit_price = 3;
+    // in thousands of percent points
+    // (ie 23% == 23000)
+    uint64 vat = 4;
+}
+
+message ContactPoint {
+    string medium = 1;
+    string contact = 2;
+}
+
+message Invoice {
+    repeated Item item = 1;
+    repeated string invoicer_billing = 2;
+    repeated ContactPoint invoicer_contact = 3;
+    repeated string customer_billing = 4;
+    string invoicer_vat_id = 5;
+    string invoicer_company_number = 12;
+    string customer_vat_id = 6;
+    bool reverse_vat = 7;
+    bool us_customer = 11;
+    int64 days_due = 8;
+    string iban = 9;
+    string swift = 10;
+}
+
+message CreateInvoiceRequest {
+    Invoice invoice = 1;
+}
+
+message CreateInvoiceResponse {
+    // Unique invoice ID
+    string uid = 1;
+}
+
+message GetInvoiceRequest {
+    string uid = 1;
+}
+
+message GetInvoiceResponse {
+    Invoice invoice = 1;
+    enum State {
+        STATE_INVALID = 0;
+        STATE_PROFORMA = 1;
+        STATE_SEALED = 2;
+    };
+    State state = 2;
+    string final_uid = 3;
+}
+
+message RenderInvoiceRequest {
+    string uid = 1;
+}
+
+message RenderInvoiceResponse {
+    bytes data = 1;
+}
+
+message SealInvoiceRequest {
+    string uid = 1;
+}
+
+message SealInvoiceResponse {
+}
+
+service Invoicer {
+    rpc CreateInvoice(CreateInvoiceRequest) returns (CreateInvoiceResponse);
+    rpc GetInvoice(GetInvoiceRequest) returns (GetInvoiceResponse);
+    rpc RenderInvoice(RenderInvoiceRequest) returns (stream RenderInvoiceResponse);
+    rpc SealInvoice(SealInvoiceRequest) returns (SealInvoiceResponse);
+}