diff --git a/go/svc/invoice/BUILD.bazel b/go/svc/invoice/BUILD.bazel index 0f8bd8bf..05fcd237 100644 --- a/go/svc/invoice/BUILD.bazel +++ b/go/svc/invoice/BUILD.bazel @@ -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", diff --git a/go/svc/invoice/calc.go b/go/svc/invoice/calc.go new file mode 100644 index 00000000..5df763f5 --- /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 33bc125b..11733c47 100644 --- a/go/svc/invoice/main.go +++ b/go/svc/invoice/main.go @@ -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 } diff --git a/go/svc/invoice/model.go b/go/svc/invoice/model.go index cb15a166..701807a2 100644 --- a/go/svc/invoice/model.go +++ b/go/svc/invoice/model.go @@ -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 diff --git a/go/svc/invoice/render.go b/go/svc/invoice/render.go index a433fa84..68e34723 100644 --- a/go/svc/invoice/render.go +++ b/go/svc/invoice/render.go @@ -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 diff --git a/go/svc/invoice/statusz.go b/go/svc/invoice/statusz.go index dbc59f10..f0762994 100644 --- a/go/svc/invoice/statusz.go +++ b/go/svc/invoice/statusz.go @@ -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 = ` Actions {{ range .Invoices }} - {{ if .Sealed }} + {{ if eq .State 2 }} {{ else }} {{ end }} - {{ .ID }} - {{ .Number }} - {{ index .Proto.CustomerBilling 0 }} + {{ .Uid }} + {{ .FinalUid }} + {{ index .Data.CustomerBilling 0 }} {{ .TotalNetPretty }} - View + View {{ 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), } } diff --git a/proto/invoice/invoice.proto b/proto/invoice/invoice.proto index 90fc8741..187e3581 100644 --- a/proto/invoice/invoice.proto +++ b/proto/invoice/invoice.proto @@ -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 {