go/svc/invoice: refactor

We unify calculation logic, move the existing Invoice proto message into
InvoiceData, and create other messages/fields around it to hold
denormalized data.
diff --git a/go/svc/invoice/BUILD.bazel b/go/svc/invoice/BUILD.bazel
index 0f8bd8b..05fcd23 100644
--- a/go/svc/invoice/BUILD.bazel
+++ b/go/svc/invoice/BUILD.bazel
@@ -3,6 +3,7 @@
 go_library(
     name = "go_default_library",
     srcs = [
+        "calc.go",
         "main.go",
         "model.go",
         "render.go",
diff --git a/go/svc/invoice/calc.go b/go/svc/invoice/calc.go
new file mode 100644
index 0000000..5df763f
--- /dev/null
+++ b/go/svc/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/go/svc/invoice/main.go b/go/svc/invoice/main.go
index 33bc125..11733c4 100644
--- a/go/svc/invoice/main.go
+++ b/go/svc/invoice/main.go
@@ -23,33 +23,33 @@
 }
 
 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 req.InvoiceData == nil {
+		return nil, status.Error(codes.InvalidArgument, "invoice data must be given")
 	}
-	if len(req.Invoice.Item) < 1 {
-		return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one item")
+	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.Invoice.Item {
+	for i, item := range req.InvoiceData.Item {
 		if item.Title == "" {
-			return nil, status.Errorf(codes.InvalidArgument, "invoice item %d must have title set", i)
+			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 item %d must have correct count", i)
+			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 item %d must have correct unit price", i)
+			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 item %d must have correct vat set", i)
+			return nil, status.Errorf(codes.InvalidArgument, "invoice data 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.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.Invoice.InvoicerBilling) < 1 {
-		return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one line of the invoicer'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.Invoice.InvoicerContact {
+	for i, c := range req.InvoiceData.InvoicerContact {
 		if c.Medium == "" {
 			return nil, status.Errorf(codes.InvalidArgument, "contact point %d must have medium set", i)
 		}
@@ -57,11 +57,11 @@
 			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")
+	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.Invoice)
+	uid, err := s.m.createInvoice(ctx, req.InvoiceData)
 	if err != nil {
 		if _, ok := status.FromError(err); ok {
 			return nil, err
@@ -83,25 +83,9 @@
 		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
 }
 
@@ -131,7 +115,7 @@
 			return nil, err
 		}
 
-		rendered, err = renderInvoicePDF(invoice, "xxxx", true)
+		rendered, err = renderInvoicePDF(invoice)
 		if err != nil {
 			return nil, err
 		}
diff --git a/go/svc/invoice/model.go b/go/svc/invoice/model.go
index cb15a16..701807a 100644
--- a/go/svc/invoice/model.go
+++ b/go/svc/invoice/model.go
@@ -5,6 +5,7 @@
 	"database/sql"
 	"fmt"
 	"strconv"
+	"time"
 
 	"github.com/golang/glog"
 	"github.com/golang/protobuf/proto"
@@ -33,12 +34,14 @@
 	_, 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 (
@@ -69,14 +72,16 @@
 
 	q := `
 		insert into invoice_seal (
-			invoice_id, final_uid
+			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 ))
+			( 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)
+	sealTime := time.Now()
+	res, err := tx.Exec(q, id, sealTime.UnixNano())
 	if err != nil {
 		return err
 	}
@@ -95,20 +100,24 @@
 		return err
 	}
 
+	invoice.State = pb.Invoice_STATE_SEALED
+	invoice.FinalUid = fmt.Sprintf("FV/%s", finalUid)
+	invoice.Date = sealTime.UnixNano()
+	calculateInvoiceData(invoice)
+
+	pdfBlob, err := renderInvoicePDF(invoice)
+	if 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
 	}
@@ -120,30 +129,30 @@
 	return nil
 }
 
-func (m *model) createInvoice(ctx context.Context, i *pb.Invoice) (string, error) {
-	data, err := proto.Marshal(i)
+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
+			proto, created_time
 		) values (
-			?
+			?, ?
 		)
 	`
-	res, err := m.db.Exec(sql, data)
+	res, err := m.db.Exec(sql, data, time.Now().UnixNano())
 	if err != nil {
 		return "", err
 	}
-	id, err := res.LastInsertId()
+	uid, err := res.LastInsertId()
 	if err != nil {
 		return "", err
 	}
 
-	glog.Infof("%+v", id)
-	return fmt.Sprintf("%d", id), nil
+	glog.Infof("%+v", uid)
+	return fmt.Sprintf("%d", uid), nil
 }
 
 func (m *model) getRendered(ctx context.Context, uid string) ([]byte, error) {
@@ -187,6 +196,37 @@
 	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 {
@@ -194,78 +234,51 @@
 	}
 
 	q := `
-		select invoice.proto from invoice where invoice.id = ?
+		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)
-	data := []byte{}
-	if err := res.Scan(&data); err != nil {
+	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
 	}
 
-	p := &pb.Invoice{}
-	if err := proto.Unmarshal(data, p); err != nil {
-		return nil, err
-	}
-	return p, nil
+	return row.Proto()
 }
 
-type invoice struct {
-	ID       int64
-	Number   string
-	Sealed   bool
-	Proto    *pb.Invoice
-	TotalNet int
-	Total    int
-}
-
-func (m *model) getInvoices(ctx context.Context) ([]invoice, error) {
+func (m *model) getInvoices(ctx context.Context) ([]*pb.Invoice, error) {
 	q := `
-		select invoice_seal.final_uid, invoice.id, invoice.proto from invoice
+		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 []invoice{}, err
+		return nil, err
 	}
 	defer rows.Close()
 
-	res := []invoice{}
+	res := []*pb.Invoice{}
+
 	for rows.Next() {
-		i := invoice{
-			Proto: &pb.Invoice{},
+		row := sqlInvoiceSealRow{}
+		if err := rows.Scan(&row.uid, &row.proto, &row.createdTime, &row.sealedTime, &row.finalUid); err != nil {
+			return nil, err
 		}
-		buf := []byte{}
-
-		number := sql.NullString{}
-		if err := rows.Scan(&number, &i.ID, &buf); err != nil {
-			return []invoice{}, err
+		p, err := row.Proto()
+		if err != nil {
+			return nil, err
 		}
-
-		if err := proto.Unmarshal(buf, i.Proto); err != nil {
-			return []invoice{}, err
-		}
-
-		if number.Valid {
-			i.Sealed = true
-			i.Number = number.String
-		} else {
-			i.Number = "proforma"
-		}
-
-		i.Total = 0
-		i.TotalNet = 0
-		for _, it := range i.Proto.Item {
-			rowTotalNet := int(it.UnitPrice * it.Count)
-			rowTotal := int(float64(rowTotalNet) * (float64(1) + float64(it.Vat)/100000))
-			i.TotalNet += rowTotalNet
-			i.Total += rowTotal
-		}
-
-		res = append(res, i)
+		res = append(res, p)
 	}
 
 	return res, nil
diff --git a/go/svc/invoice/render.go b/go/svc/invoice/render.go
index a433fa8..68e3472 100644
--- a/go/svc/invoice/render.go
+++ b/go/svc/invoice/render.go
@@ -24,9 +24,7 @@
 	invTmpl = template.Must(template.New("invoice.html").Parse(string(a)))
 }
 
-func renderInvoicePDF(i *pb.Invoice, number string, proforma bool) ([]byte, error) {
-	now := time.Now()
-
+func renderInvoicePDF(i *pb.Invoice) ([]byte, error) {
 	type item struct {
 		Title     string
 		UnitPrice string
@@ -56,55 +54,45 @@
 		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,
+		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.InvoicerBilling)),
-		InvoiceeBilling: make([]string, len(i.CustomerBilling)),
+		InvoicerBilling: make([]string, len(i.Data.InvoicerBilling)),
+		InvoiceeBilling: make([]string, len(i.Data.CustomerBilling)),
 	}
 
-	unit := i.Unit
-	if unit == "" {
-		unit = "€"
-	}
-
-	for d, s := range i.InvoicerBilling {
+	for d, s := range i.Data.InvoicerBilling {
 		data.InvoicerBilling[d] = s
 	}
-	for d, s := range i.CustomerBilling {
+	for d, s := range i.Data.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))
+	unit := i.Unit
 
-		totalNet += rowTotalNet
-		total += rowTotal
+	for _, it := range i.Data.Item {
 		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),
+			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(totalNet)/100)
-	data.VATTotal = fmt.Sprintf(unit+"%.2f", float64(total-totalNet)/100)
-	data.Total = fmt.Sprintf(unit+"%.2f", float64(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))
 
 	var b bytes.Buffer
diff --git a/go/svc/invoice/statusz.go b/go/svc/invoice/statusz.go
index dbc59f1..f076299 100644
--- a/go/svc/invoice/statusz.go
+++ b/go/svc/invoice/statusz.go
@@ -7,6 +7,7 @@
 
 	"code.hackerspace.pl/hscloud/go/mirko"
 	"code.hackerspace.pl/hscloud/go/statusz"
+	pb "code.hackerspace.pl/hscloud/proto/invoice"
 	"github.com/golang/glog"
 )
 
@@ -37,17 +38,17 @@
 				<th>Actions</th>
             </tr>
             {{ range .Invoices }}
-				{{ if .Sealed }}
+				{{ if eq .State 2 }}
 				<tr>
 				{{ else }}
 				<tr style="opacity: 0.5">
 				{{ end }}
-				    <td>{{ .ID }}</td>
-				    <td>{{ .Number }}</td>
-					<td>{{ index .Proto.CustomerBilling 0 }}</td>
+				    <td>{{ .Uid }}</td>
+				    <td>{{ .FinalUid }}</td>
+					<td>{{ index .Data.CustomerBilling 0 }}</td>
 					<td>{{ .TotalNetPretty }}</td>
 				    <td>
-						<a href="/debug/view?id={{ .ID }}">View</a>
+						<a href="/debug/view?id={{ .Uid }}">View</a>
 					</td>
 				</tr>
 			{{ end }}
@@ -56,7 +57,7 @@
 `
 
 type templateInvoice struct {
-	invoice
+	*pb.Invoice
 	TotalNetPretty string
 }
 
@@ -70,8 +71,8 @@
 		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.Proto.Unit),
+				Invoice:        inv,
+				TotalNetPretty: fmt.Sprintf("%.2f %s", float64(inv.TotalNet)/100, inv.Unit),
 			}
 		}