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.
This commit is contained in:
q3k 2019-05-01 15:27:49 +02:00
parent 57ef6b0d7f
commit 3976e3cee8
7 changed files with 190 additions and 158 deletions

View file

@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library( go_library(
name = "go_default_library", name = "go_default_library",
srcs = [ srcs = [
"calc.go",
"main.go", "main.go",
"model.go", "model.go",
"render.go", "render.go",

29
go/svc/invoice/calc.go Normal file
View file

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

View file

@ -23,33 +23,33 @@ type service struct {
} }
func (s *service) CreateInvoice(ctx context.Context, req *pb.CreateInvoiceRequest) (*pb.CreateInvoiceResponse, error) { func (s *service) CreateInvoice(ctx context.Context, req *pb.CreateInvoiceRequest) (*pb.CreateInvoiceResponse, error) {
if req.Invoice == nil { if req.InvoiceData == nil {
return nil, status.Error(codes.InvalidArgument, "invoice must be given") return nil, status.Error(codes.InvalidArgument, "invoice data must be given")
} }
if len(req.Invoice.Item) < 1 { if len(req.InvoiceData.Item) < 1 {
return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one item") 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 == "" { 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 { 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 { 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 { 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 { if len(req.InvoiceData.CustomerBilling) < 1 {
return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one line of the customer's billing address") 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 { if len(req.InvoiceData.InvoicerBilling) < 1 {
return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one line of the invoicer's billing address") 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 == "" { if c.Medium == "" {
return nil, status.Errorf(codes.InvalidArgument, "contact point %d must have medium set", i) return nil, status.Errorf(codes.InvalidArgument, "contact point %d must have medium set", i)
} }
@ -57,11 +57,11 @@ func (s *service) CreateInvoice(ctx context.Context, req *pb.CreateInvoiceReques
return nil, status.Errorf(codes.InvalidArgument, "contact point %d must have contact set", i) return nil, status.Errorf(codes.InvalidArgument, "contact point %d must have contact set", i)
} }
} }
if req.Invoice.InvoicerVatId == "" { if req.InvoiceData.InvoicerVatId == "" {
return nil, status.Error(codes.InvalidArgument, "invoice must contain invoicer's vat id") 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 err != nil {
if _, ok := status.FromError(err); ok { if _, ok := status.FromError(err); ok {
return nil, err return nil, err
@ -83,25 +83,9 @@ func (s *service) GetInvoice(ctx context.Context, req *pb.GetInvoiceRequest) (*p
glog.Errorf("getInvoice(_, %q): %v", req.Uid, err) glog.Errorf("getInvoice(_, %q): %v", req.Uid, err)
return nil, status.Error(codes.Unavailable, "internal server error") 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{ res := &pb.GetInvoiceResponse{
Invoice: invoice, Invoice: invoice,
} }
if sealedUid == "" {
res.State = pb.GetInvoiceResponse_STATE_PROFORMA
} else {
res.State = pb.GetInvoiceResponse_STATE_SEALED
res.FinalUid = sealedUid
}
return res, nil return res, nil
} }
@ -131,7 +115,7 @@ func (s *service) invoicePDF(ctx context.Context, uid string) ([]byte, error) {
return nil, err return nil, err
} }
rendered, err = renderInvoicePDF(invoice, "xxxx", true) rendered, err = renderInvoicePDF(invoice)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strconv" "strconv"
"time"
"github.com/golang/glog" "github.com/golang/glog"
"github.com/golang/protobuf/proto" "github.com/golang/protobuf/proto"
@ -33,12 +34,14 @@ func (m *model) init() error {
_, err := m.db.Exec(` _, err := m.db.Exec(`
create table invoice ( create table invoice (
id integer primary key not null, id integer primary key not null,
created_time integer not null,
proto blob not null proto blob not null
); );
create table invoice_seal ( create table invoice_seal (
id integer primary key not null, id integer primary key not null,
invoice_id integer not null, invoice_id integer not null,
final_uid text not null unique, final_uid text not null unique,
sealed_time integer not null,
foreign key (invoice_id) references invoice(id) foreign key (invoice_id) references invoice(id)
); );
create table invoice_blob ( create table invoice_blob (
@ -69,14 +72,16 @@ func (m *model) sealInvoice(ctx context.Context, uid string) error {
q := ` q := `
insert into invoice_seal ( insert into invoice_seal (
invoice_id, final_uid invoice_id, final_uid, sealed_time
) values ( ) 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 { if err != nil {
return err return err
} }
@ -95,20 +100,24 @@ func (m *model) sealInvoice(ctx context.Context, uid string) error {
return err 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 = ` q = `
insert into invoice_blob ( insert into invoice_blob (
invoice_id, pdf invoice_id, pdf
) values ( ) values (
?, ?, ?
?
) )
` `
pdfBlob, err := renderInvoicePDF(invoice, finalUid, false)
if err != nil {
return err
}
if _, err := tx.Exec(q, id, pdfBlob); err != nil { if _, err := tx.Exec(q, id, pdfBlob); err != nil {
return err return err
} }
@ -120,30 +129,30 @@ func (m *model) sealInvoice(ctx context.Context, uid string) error {
return nil return nil
} }
func (m *model) createInvoice(ctx context.Context, i *pb.Invoice) (string, error) { func (m *model) createInvoice(ctx context.Context, id *pb.InvoiceData) (string, error) {
data, err := proto.Marshal(i) data, err := proto.Marshal(id)
if err != nil { if err != nil {
return "", err return "", err
} }
sql := ` sql := `
insert into invoice ( insert into invoice (
proto proto, created_time
) values ( ) values (
? ?, ?
) )
` `
res, err := m.db.Exec(sql, data) res, err := m.db.Exec(sql, data, time.Now().UnixNano())
if err != nil { if err != nil {
return "", err return "", err
} }
id, err := res.LastInsertId() uid, err := res.LastInsertId()
if err != nil { if err != nil {
return "", err return "", err
} }
glog.Infof("%+v", id) glog.Infof("%+v", uid)
return fmt.Sprintf("%d", id), nil return fmt.Sprintf("%d", uid), nil
} }
func (m *model) getRendered(ctx context.Context, uid string) ([]byte, error) { func (m *model) getRendered(ctx context.Context, uid string) ([]byte, error) {
@ -187,6 +196,37 @@ func (m *model) getSealedUid(ctx context.Context, uid string) (string, error) {
return finalUid, nil 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) { func (m *model) getInvoice(ctx context.Context, uid string) (*pb.Invoice, error) {
id, err := strconv.Atoi(uid) id, err := strconv.Atoi(uid)
if err != nil { if err != nil {
@ -194,78 +234,51 @@ func (m *model) getInvoice(ctx context.Context, uid string) (*pb.Invoice, error)
} }
q := ` 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) res := m.db.QueryRow(q, id)
data := []byte{} row := sqlInvoiceSealRow{}
if err := res.Scan(&data); err != nil { if err := res.Scan(&row.uid, &row.proto, &row.createdTime, &row.sealedTime, &row.finalUid); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, status.Error(codes.NotFound, "no such invoice") return nil, status.Error(codes.NotFound, "no such invoice")
} }
return nil, err return nil, err
} }
p := &pb.Invoice{} return row.Proto()
if err := proto.Unmarshal(data, p); err != nil {
return nil, err
}
return p, nil
} }
type invoice struct { func (m *model) getInvoices(ctx context.Context) ([]*pb.Invoice, error) {
ID int64
Number string
Sealed bool
Proto *pb.Invoice
TotalNet int
Total int
}
func (m *model) getInvoices(ctx context.Context) ([]invoice, error) {
q := ` 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 left join invoice_seal
on invoice_seal.invoice_id = invoice.id on invoice_seal.invoice_id = invoice.id
` `
rows, err := m.db.QueryContext(ctx, q) rows, err := m.db.QueryContext(ctx, q)
if err != nil { if err != nil {
return []invoice{}, err return nil, err
} }
defer rows.Close() defer rows.Close()
res := []invoice{} res := []*pb.Invoice{}
for rows.Next() { for rows.Next() {
i := invoice{ row := sqlInvoiceSealRow{}
Proto: &pb.Invoice{}, if err := rows.Scan(&row.uid, &row.proto, &row.createdTime, &row.sealedTime, &row.finalUid); err != nil {
return nil, err
} }
buf := []byte{} p, err := row.Proto()
if err != nil {
number := sql.NullString{} return nil, err
if err := rows.Scan(&number, &i.ID, &buf); err != nil {
return []invoice{}, err
} }
res = append(res, p)
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)
} }
return res, nil return res, nil

View file

@ -24,9 +24,7 @@ func init() {
invTmpl = template.Must(template.New("invoice.html").Parse(string(a))) invTmpl = template.Must(template.New("invoice.html").Parse(string(a)))
} }
func renderInvoicePDF(i *pb.Invoice, number string, proforma bool) ([]byte, error) { func renderInvoicePDF(i *pb.Invoice) ([]byte, error) {
now := time.Now()
type item struct { type item struct {
Title string Title string
UnitPrice string UnitPrice string
@ -56,55 +54,45 @@ func renderInvoicePDF(i *pb.Invoice, number string, proforma bool) ([]byte, erro
Total string Total string
DeliveryCharge string DeliveryCharge string
}{ }{
InvoiceNumber: number, InvoiceNumber: i.FinalUid,
Date: now, Date: time.Unix(0, i.Date),
DueDate: now.AddDate(0, 0, int(i.DaysDue)), DueDate: time.Unix(0, i.DueDate),
IBAN: i.Iban, IBAN: i.Data.Iban,
SWIFT: i.Swift, SWIFT: i.Data.Swift,
InvoicerVAT: i.InvoicerVatId, InvoicerVAT: i.Data.InvoicerVatId,
InvoicerCompanyNumber: i.InvoicerCompanyNumber, InvoicerCompanyNumber: i.Data.InvoicerCompanyNumber,
InvoiceeVAT: i.CustomerVatId, InvoiceeVAT: i.Data.CustomerVatId,
Proforma: proforma, Proforma: i.State == pb.Invoice_STATE_PROFORMA,
ReverseVAT: i.ReverseVat, ReverseVAT: i.Data.ReverseVat,
USCustomer: i.UsCustomer, USCustomer: i.Data.UsCustomer,
InvoicerBilling: make([]string, len(i.InvoicerBilling)), InvoicerBilling: make([]string, len(i.Data.InvoicerBilling)),
InvoiceeBilling: make([]string, len(i.CustomerBilling)), InvoiceeBilling: make([]string, len(i.Data.CustomerBilling)),
} }
unit := i.Unit for d, s := range i.Data.InvoicerBilling {
if unit == "" {
unit = "€"
}
for d, s := range i.InvoicerBilling {
data.InvoicerBilling[d] = s data.InvoicerBilling[d] = s
} }
for d, s := range i.CustomerBilling { for d, s := range i.Data.CustomerBilling {
data.InvoiceeBilling[d] = s data.InvoiceeBilling[d] = s
} }
totalNet := 0 unit := i.Unit
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 for _, it := range i.Data.Item {
total += rowTotal
data.Items = append(data.Items, item{ data.Items = append(data.Items, item{
Title: i.Title, Title: it.Title,
Qty: fmt.Sprintf("%d", i.Count), Qty: fmt.Sprintf("%d", it.Count),
UnitPrice: fmt.Sprintf(unit+"%.2f", float64(i.UnitPrice)/100), UnitPrice: fmt.Sprintf(unit+"%.2f", float64(it.UnitPrice)/100),
VATRate: fmt.Sprintf("%.2f%%", float64(i.Vat)/1000), VATRate: fmt.Sprintf("%.2f%%", float64(it.Vat)/1000),
TotalNet: fmt.Sprintf(unit+"%.2f", float64(rowTotalNet)/100), TotalNet: fmt.Sprintf(unit+"%.2f", float64(it.TotalNet)/100),
Total: fmt.Sprintf(unit+"%.2f", float64(rowTotal)/100), Total: fmt.Sprintf(unit+"%.2f", float64(it.Total)/100),
}) })
} }
data.TotalNet = fmt.Sprintf(unit+"%.2f", float64(totalNet)/100) data.TotalNet = fmt.Sprintf(unit+"%.2f", float64(i.TotalNet)/100)
data.VATTotal = fmt.Sprintf(unit+"%.2f", float64(total-totalNet)/100) data.VATTotal = fmt.Sprintf(unit+"%.2f", float64(i.Total-i.TotalNet)/100)
data.Total = fmt.Sprintf(unit+"%.2f", float64(total)/100) data.Total = fmt.Sprintf(unit+"%.2f", float64(i.Total)/100)
data.DeliveryCharge = fmt.Sprintf(unit+"%.2f", float64(0)) data.DeliveryCharge = fmt.Sprintf(unit+"%.2f", float64(0))
var b bytes.Buffer var b bytes.Buffer

View file

@ -7,6 +7,7 @@ import (
"code.hackerspace.pl/hscloud/go/mirko" "code.hackerspace.pl/hscloud/go/mirko"
"code.hackerspace.pl/hscloud/go/statusz" "code.hackerspace.pl/hscloud/go/statusz"
pb "code.hackerspace.pl/hscloud/proto/invoice"
"github.com/golang/glog" "github.com/golang/glog"
) )
@ -37,17 +38,17 @@ const invoicesFragment = `
<th>Actions</th> <th>Actions</th>
</tr> </tr>
{{ range .Invoices }} {{ range .Invoices }}
{{ if .Sealed }} {{ if eq .State 2 }}
<tr> <tr>
{{ else }} {{ else }}
<tr style="opacity: 0.5"> <tr style="opacity: 0.5">
{{ end }} {{ end }}
<td>{{ .ID }}</td> <td>{{ .Uid }}</td>
<td>{{ .Number }}</td> <td>{{ .FinalUid }}</td>
<td>{{ index .Proto.CustomerBilling 0 }}</td> <td>{{ index .Data.CustomerBilling 0 }}</td>
<td>{{ .TotalNetPretty }}</td> <td>{{ .TotalNetPretty }}</td>
<td> <td>
<a href="/debug/view?id={{ .ID }}">View</a> <a href="/debug/view?id={{ .Uid }}">View</a>
</td> </td>
</tr> </tr>
{{ end }} {{ end }}
@ -56,7 +57,7 @@ const invoicesFragment = `
` `
type templateInvoice struct { type templateInvoice struct {
invoice *pb.Invoice
TotalNetPretty string TotalNetPretty string
} }
@ -70,8 +71,8 @@ func (s *service) setupStatusz(m *mirko.Mirko) {
res.Invoices = make([]templateInvoice, len(invoices)) res.Invoices = make([]templateInvoice, len(invoices))
for i, inv := range invoices { for i, inv := range invoices {
res.Invoices[i] = templateInvoice{ res.Invoices[i] = templateInvoice{
invoice: inv, Invoice: inv,
TotalNetPretty: fmt.Sprintf("%.2f %s", float64(inv.TotalNet)/100, inv.Proto.Unit), TotalNetPretty: fmt.Sprintf("%.2f %s", float64(inv.TotalNet)/100, inv.Unit),
} }
} }

View file

@ -9,6 +9,9 @@ message Item {
// in thousands of percent points // in thousands of percent points
// (ie 23% == 23000) // (ie 23% == 23000)
uint64 vat = 4; uint64 vat = 4;
// Denormalized fields follow.
uint64 total_net = 5;
uint64 total = 6;
} }
message ContactPoint { message ContactPoint {
@ -16,7 +19,7 @@ message ContactPoint {
string contact = 2; string contact = 2;
} }
message Invoice { message InvoiceData {
repeated Item item = 1; repeated Item item = 1;
repeated string invoicer_billing = 2; repeated string invoicer_billing = 2;
repeated ContactPoint invoicer_contact = 3; repeated ContactPoint invoicer_contact = 3;
@ -32,8 +35,28 @@ message Invoice {
string unit = 13; string unit = 13;
} }
message Invoice {
// Original invoice parameters/data.
InvoiceData data = 1;
enum State {
STATE_INVALID = 0;
STATE_PROFORMA = 1;
STATE_SEALED = 2;
};
State state = 2;
string uid = 9;
// If sealed, otherwise 'proforma'.
string final_uid = 3;
int64 date = 4;
int64 due_date = 5;
// Denormalized fields follow.
uint64 total_net = 6;
uint64 total = 7;
string unit = 8;
}
message CreateInvoiceRequest { message CreateInvoiceRequest {
Invoice invoice = 1; InvoiceData invoice_data = 1;
} }
message CreateInvoiceResponse { message CreateInvoiceResponse {
@ -47,13 +70,6 @@ message GetInvoiceRequest {
message GetInvoiceResponse { message GetInvoiceResponse {
Invoice invoice = 1; Invoice invoice = 1;
enum State {
STATE_INVALID = 0;
STATE_PROFORMA = 1;
STATE_SEALED = 2;
};
State state = 2;
string final_uid = 3;
} }
message RenderInvoiceRequest { message RenderInvoiceRequest {