1
0
Fork 0

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.
master
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(
name = "go_default_library",
srcs = [
"calc.go",
"main.go",
"model.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) {
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 @@ func (s *service) CreateInvoice(ctx context.Context, req *pb.CreateInvoiceReques
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 @@ func (s *service) GetInvoice(ctx context.Context, req *pb.GetInvoiceRequest) (*p
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 @@ func (s *service) invoicePDF(ctx context.Context, uid string) ([]byte, error) {
return nil, err
}
rendered, err = renderInvoicePDF(invoice, "xxxx", true)
rendered, err = renderInvoicePDF(invoice)
if err != nil {
return nil, err
}

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strconv"
"time"
"github.com/golang/glog"
"github.com/golang/protobuf/proto"
@ -33,12 +34,14 @@ 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 (
@ -69,14 +72,16 @@ func (m *model) sealInvoice(ctx context.Context, uid string) error {
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 @@ func (m *model) sealInvoice(ctx context.Context, uid string) error {
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 @@ func (m *model) sealInvoice(ctx context.Context, uid string) error {
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 @@ func (m *model) getSealedUid(ctx context.Context, uid string) (string, error) {
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 @@ func (m *model) getInvoice(ctx context.Context, uid string) (*pb.Invoice, error)
}
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

View File

@ -24,9 +24,7 @@ func init() {
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 @@ func renderInvoicePDF(i *pb.Invoice, number string, proforma bool) ([]byte, erro
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

View File

@ -7,6 +7,7 @@ import (
"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 @@ const invoicesFragment = `
<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 @@ const invoicesFragment = `
`
type templateInvoice struct {
invoice
*pb.Invoice
TotalNetPretty string
}
@ -70,8 +71,8 @@ func (s *service) setupStatusz(m *mirko.Mirko) {
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),
}
}

View File

@ -9,6 +9,9 @@ message Item {
// in thousands of percent points
// (ie 23% == 23000)
uint64 vat = 4;
// Denormalized fields follow.
uint64 total_net = 5;
uint64 total = 6;
}
message ContactPoint {
@ -16,7 +19,7 @@ message ContactPoint {
string contact = 2;
}
message Invoice {
message InvoiceData {
repeated Item item = 1;
repeated string invoicer_billing = 2;
repeated ContactPoint invoicer_contact = 3;
@ -32,8 +35,28 @@ message Invoice {
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 {
Invoice invoice = 1;
InvoiceData invoice_data = 1;
}
message CreateInvoiceResponse {
@ -47,13 +70,6 @@ message GetInvoiceRequest {
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 {