go/svc/{invoice,speedtest} -> bgpwtf/
Continued from https://gerrit.hackerspace.pl/c/hscloud/+/71 .
Change-Id: I5aef587c7e9a4cec301e3c95530c33914851ad44
diff --git a/bgpwtf/invoice/BUILD.bazel b/bgpwtf/invoice/BUILD.bazel
new file mode 100644
index 0000000..b2e2ee8
--- /dev/null
+++ b/bgpwtf/invoice/BUILD.bazel
@@ -0,0 +1,32 @@
+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",
+ "statusz.go",
+ ],
+ importpath = "code.hackerspace.pl/hscloud/bgpwtf/invoice",
+ visibility = ["//visibility:private"],
+ deps = [
+ "//go/mirko:go_default_library",
+ "//go/statusz:go_default_library",
+ "//bgpwtf/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/bgpwtf/invoice/calc.go b/bgpwtf/invoice/calc.go
new file mode 100644
index 0000000..5df763f
--- /dev/null
+++ b/bgpwtf/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/bgpwtf/invoice/main.go b/bgpwtf/invoice/main.go
new file mode 100644
index 0000000..4a80441
--- /dev/null
+++ b/bgpwtf/invoice/main.go
@@ -0,0 +1,198 @@
+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.InvoiceData == nil {
+ return nil, status.Error(codes.InvalidArgument, "invoice data must be given")
+ }
+ 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.InvoiceData.Item {
+ if item.Title == "" {
+ 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 data item %d must have correct count", i)
+ }
+ if item.UnitPrice == 0 {
+ 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 data item %d must have correct vat set", i)
+ }
+ }
+ 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.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.InvoiceData.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.InvoiceData.InvoicerVatId == "" {
+ return nil, status.Error(codes.InvalidArgument, "invoice data must contain invoicer's vat id")
+ }
+
+ uid, err := s.m.createInvoice(ctx, req.InvoiceData)
+ 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")
+ }
+ res := &pb.GetInvoiceResponse{
+ Invoice: invoice,
+ }
+ return res, nil
+}
+
+func newService(m *model) *service {
+ return &service{
+ m: m,
+ }
+}
+
+func (s *service) invoicePDF(ctx context.Context, uid, language string) ([]byte, error) {
+ sealed, err := s.m.getSealedUid(ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+
+ var rendered []byte
+ if sealed != "" {
+ // Invoice is sealed, return stored PDF.
+ rendered, err = s.m.getRendered(ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ // Invoice is proforma, render.
+ invoice, err := s.m.getInvoice(ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+
+ rendered, err = renderInvoicePDF(invoice, language)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return rendered, nil
+}
+
+func (s *service) RenderInvoice(req *pb.RenderInvoiceRequest, srv pb.Invoicer_RenderInvoiceServer) error {
+ rendered, err := s.invoicePDF(srv.Context(), req.Uid, req.Language)
+ if err != nil {
+ if _, ok := status.FromError(err); ok {
+ return err
+ }
+ glog.Errorf("invoicePDF(_, %q): %v", req.Uid, 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) {
+ useProformaTime := false
+ if req.DateSource == pb.SealInvoiceRequest_DATE_SOURCE_PROFORMA {
+ useProformaTime = true
+ }
+ if err := s.m.sealInvoice(ctx, req.Uid, req.Language, useProformaTime); 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)
+ s.setupStatusz(mi)
+ pb.RegisterInvoicerServer(mi.GRPC(), s)
+
+ if err := mi.Serve(); err != nil {
+ glog.Exitf("Serve failed: %v", err)
+ }
+
+ <-mi.Done()
+}
diff --git a/bgpwtf/invoice/model.go b/bgpwtf/invoice/model.go
new file mode 100644
index 0000000..d628e1a
--- /dev/null
+++ b/bgpwtf/invoice/model.go
@@ -0,0 +1,295 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strconv"
+ "time"
+
+ "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,
+ 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 (
+ 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, language string, useProformaTime bool) 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, sealed_time
+ ) values (
+ ?,
+ ( select printf("%04d", ifnull( (select final_uid as v from invoice_seal order by final_uid desc limit 1), 19000) + 1 )),
+ ?
+ )
+
+ `
+ sealTime := time.Now()
+ if useProformaTime {
+ sealTime = time.Unix(0, invoice.Date)
+ }
+ res, err := tx.Exec(q, id, sealTime.UnixNano())
+ 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
+ }
+
+ invoice.State = pb.Invoice_STATE_SEALED
+ // TODO(q3k): this should be configurable.
+ invoice.FinalUid = fmt.Sprintf("FV/%s", finalUid)
+ invoice.Date = sealTime.UnixNano()
+ calculateInvoiceData(invoice)
+
+ pdfBlob, err := renderInvoicePDF(invoice, language)
+ if err != nil {
+ return err
+ }
+
+ q = `
+ insert into invoice_blob (
+ invoice_id, pdf
+ ) values (
+ ?, ?
+ )
+ `
+
+ 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, id *pb.InvoiceData) (string, error) {
+ data, err := proto.Marshal(id)
+ if err != nil {
+ return "", err
+ }
+
+ sql := `
+ insert into invoice (
+ proto, created_time
+ ) values (
+ ?, ?
+ )
+ `
+
+ t := time.Now()
+ if id.Date != 0 {
+ t = time.Unix(0, id.Date)
+ }
+
+ res, err := m.db.Exec(sql, data, t.UnixNano())
+ if err != nil {
+ return "", err
+ }
+ uid, err := res.LastInsertId()
+ if err != nil {
+ return "", err
+ }
+
+ glog.Infof("%+v", uid)
+ return fmt.Sprintf("%d", uid), 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
+}
+
+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 {
+ return nil, status.Error(codes.InvalidArgument, "invalid uid")
+ }
+
+ q := `
+ 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)
+ 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
+ }
+
+ return row.Proto()
+}
+
+func (m *model) getInvoices(ctx context.Context) ([]*pb.Invoice, error) {
+ q := `
+ 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 nil, err
+ }
+ defer rows.Close()
+
+ res := []*pb.Invoice{}
+
+ for rows.Next() {
+ row := sqlInvoiceSealRow{}
+ if err := rows.Scan(&row.uid, &row.proto, &row.createdTime, &row.sealedTime, &row.finalUid); err != nil {
+ return nil, err
+ }
+ p, err := row.Proto()
+ if err != nil {
+ return nil, err
+ }
+ res = append(res, p)
+ }
+
+ return res, nil
+}
diff --git a/bgpwtf/invoice/proto/BUILD.bazel b/bgpwtf/invoice/proto/BUILD.bazel
new file mode 100644
index 0000000..511bf26
--- /dev/null
+++ b/bgpwtf/invoice/proto/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = ["generate.go"],
+ importpath = "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto",
+ visibility = ["//visibility:public"],
+)
diff --git a/bgpwtf/invoice/proto/generate.go b/bgpwtf/invoice/proto/generate.go
new file mode 100644
index 0000000..b0f6618
--- /dev/null
+++ b/bgpwtf/invoice/proto/generate.go
@@ -0,0 +1,3 @@
+package proto
+
+//go:generate protoc -I.. ../inboice.proto --go_out=plugins=grpc:.
diff --git a/bgpwtf/invoice/render.go b/bgpwtf/invoice/render.go
new file mode 100644
index 0000000..2353014
--- /dev/null
+++ b/bgpwtf/invoice/render.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "time"
+
+ wkhtml "github.com/sebastiaanklippert/go-wkhtmltopdf"
+
+ "code.hackerspace.pl/hscloud/bgpwtf/invoice/templates"
+ pb "code.hackerspace.pl/hscloud/proto/invoice"
+)
+
+var (
+ invTmpl map[string]*template.Template
+
+ languages = []string{"en", "pl"}
+ defaultLanguage = "en"
+)
+
+func init() {
+ invTmpl = make(map[string]*template.Template)
+ for _, language := range languages {
+ filename := fmt.Sprintf("invoice_%s.html", language)
+ a, err := templates.Asset(filename)
+ if err != nil {
+ panic(err)
+ }
+ invTmpl[language] = template.Must(template.New(filename).Parse(string(a)))
+ }
+}
+
+func renderInvoicePDF(i *pb.Invoice, language string) ([]byte, error) {
+ 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: 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.Data.InvoicerBilling)),
+ InvoiceeBilling: make([]string, len(i.Data.CustomerBilling)),
+ }
+
+ for d, s := range i.Data.InvoicerBilling {
+ data.InvoicerBilling[d] = s
+ }
+ for d, s := range i.Data.CustomerBilling {
+ data.InvoiceeBilling[d] = s
+ }
+
+ unit := i.Unit
+
+ for _, it := range i.Data.Item {
+ data.Items = append(data.Items, item{
+ 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(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))
+
+ if _, ok := invTmpl[language]; !ok {
+ language = defaultLanguage
+ }
+
+ var b bytes.Buffer
+ err := invTmpl[language].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/bgpwtf/invoice/statusz.go b/bgpwtf/invoice/statusz.go
new file mode 100644
index 0000000..0a64ce4
--- /dev/null
+++ b/bgpwtf/invoice/statusz.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sort"
+ "time"
+
+ "code.hackerspace.pl/hscloud/go/mirko"
+ "code.hackerspace.pl/hscloud/go/statusz"
+ pb "code.hackerspace.pl/hscloud/proto/invoice"
+ "github.com/golang/glog"
+)
+
+const invoicesFragment = `
+ <style type="text/css">
+ .table td,th {
+ background-color: #eee;
+ padding: 0.2em 0.4em 0.2em 0.4em;
+ }
+ .table th {
+ background-color: #c0c0c0;
+ }
+ .table {
+ background-color: #fff;
+ border-spacing: 0.2em;
+ margin-left: auto;
+ margin-right: auto;
+ }
+ </style>
+ <div>
+ {{ .Msg }}
+ <table class="table">
+ <tr>
+ <th>Number</th>
+ <th>Created/Issued</th>
+ <th>Customer</th>
+ <th>Amount (net)</th>
+ <th>Actions</th>
+ </tr>
+ {{ range .Invoices }}
+ {{ if eq .State 2 }}
+ <tr>
+ {{ else }}
+ <tr style="opacity: 0.5">
+ {{ end }}
+ <td>{{ .FinalUid }}</td>
+ <td>{{ .DatePretty.Format "2006/01/02 15:04:05" }}</td>
+ <td>{{ index .Data.CustomerBilling 0 }}</td>
+ <td>{{ .TotalNetPretty }}</td>
+ <td>
+ {{ if eq .State 2 }}
+ <a href="/debug/view?id={{ .Uid }}">View</a>
+ {{ else }}
+ <a href="/debug/view?id={{ .Uid }}&language=en">Preview (en)</a> |
+ <a href="/debug/view?id={{ .Uid }}&language=pl">Preview (pl)</a>
+ {{ end }}
+ </td>
+ </tr>
+ {{ end }}
+ </table>
+ </div>
+`
+
+type templateInvoice struct {
+ *pb.Invoice
+ TotalNetPretty string
+ DatePretty time.Time
+}
+
+func (s *service) setupStatusz(m *mirko.Mirko) {
+ statusz.AddStatusPart("Invoices", invoicesFragment, func(ctx context.Context) interface{} {
+ var res struct {
+ Invoices []templateInvoice
+ Msg string
+ }
+ invoices, err := s.m.getInvoices(ctx)
+ 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.Unit),
+ DatePretty: time.Unix(0, inv.Date),
+ }
+ }
+
+ if err != nil {
+ glog.Errorf("Could not get invoices for statusz: %v", err)
+ res.Msg = fmt.Sprintf("Could not get invoices: %v", err)
+ }
+
+ sort.Slice(res.Invoices, func(i, j int) bool { return res.Invoices[i].Date > res.Invoices[j].Date })
+ return res
+ })
+
+ m.HTTPMux().HandleFunc("/debug/view", func(w http.ResponseWriter, r *http.Request) {
+ rendered, err := s.invoicePDF(r.Context(), r.URL.Query().Get("id"), r.URL.Query().Get("language"))
+ if err != nil {
+ fmt.Fprintf(w, "error: %v", err)
+ }
+ w.Header().Set("Content-type", "application/pdf")
+ w.Write(rendered)
+ })
+}
diff --git a/bgpwtf/invoice/templates/BUILD.bazel b/bgpwtf/invoice/templates/BUILD.bazel
new file mode 100644
index 0000000..4756da4
--- /dev/null
+++ b/bgpwtf/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/bgpwtf/invoice/templates", # keep
+ visibility = ["//bgpwtf/invoice:__subpackages__"],
+)
diff --git a/bgpwtf/invoice/templates/invoice_en.html b/bgpwtf/invoice/templates/invoice_en.html
new file mode 100644
index 0000000..d661732
--- /dev/null
+++ b/bgpwtf/invoice/templates/invoice_en.html
@@ -0,0 +1,199 @@
+<!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;
+}
+h2 {
+ font-size: 1.2em;
+ margin: 0;
+}
+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>{{ .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><b>NIP:</b> {{ .InvoiceeVAT }}</li>
+ {{ end }}
+
+ {{ if .ReverseVAT }}
+ <li><b>(reverse charge / obciążenie odwrotne)</b></li>
+ {{ end }}
+ </ul>
+ </div>
+ </div>
+ <div style="clear: both; height: 1em;"></div>
+ <table class="items">
+ <tr>
+ <th style="width: 60%;">Name of goods / service</th>
+ <th>Unit price<br />(net)</th>
+ <th>Qty</th>
+ {{ if not .ReverseVAT }}
+ <th>VAT rate</th>
+ {{ end }}
+ <th>Goods value<br />(net)</th>
+ {{ if not .ReverseVAT }}
+ <th>Goods value<br />(gross)</th>
+ {{ end }}
+ </tr>
+ {{ range .Items }}
+ <tr>
+ <td style="text-align: left;">{{ .Title }}</td>
+ <td>{{ .UnitPrice }}</td>
+ <td>{{ .Qty }}</td>
+ {{ if not $.ReverseVAT }}
+ <td>{{ .VATRate }}</td>
+ {{ end }}
+ <td>{{ .TotalNet }}</td>
+ {{ if not $.ReverseVAT }}
+ <td>{{ .Total }}</td>
+ {{ end }}
+ </tr>
+ {{ end }}
+ {{ if not .ReverseVAT }}
+ <tr>
+ <td colspan="5" class="lhead">Subtotal without VAT</td>
+ <td>{{ .TotalNet }}</td>
+ </tr>
+ <tr>
+ <td colspan="5" class="lhead">VAT Total {{ if .USCustomer }}(VAT zero rate){{ end }}</td>
+ <td>{{ .VATTotal }}</td>
+ </tr>
+ {{ end }}
+ <tr>
+ <td colspan="{{ if .ReverseVAT }}3{{ else }}5{{ end }}" class="lhead"><b>Total</b></td>
+ <td><b>{{ .Total }}</b></td>
+ </tr>
+ </table>
+ </body>
+</html>
diff --git a/bgpwtf/invoice/templates/invoice_pl.html b/bgpwtf/invoice/templates/invoice_pl.html
new file mode 100644
index 0000000..df49da8
--- /dev/null
+++ b/bgpwtf/invoice/templates/invoice_pl.html
@@ -0,0 +1,190 @@
+<!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;
+}
+h2 {
+ font-size: 1.2em;
+ margin: 0;
+}
+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>{{ .InvoicerCompanyNumber }}</li>
+ {{ end }}
+ <li><b>NIP:</b> {{ .InvoicerVAT }}</li>
+ </ul>
+ </div>
+ <div class="metadata">
+ <ul>
+ <li><b>Numer faktury:</b> {{ .InvoiceNumber }}</li>
+ <li><b>Data wystawienia:</b> {{ .Date.Format "2006/01/02" }}</li>
+ <li><b>Termin płatności:</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>Faktura Proforma</h1>
+ {{ else }}
+ <h1>Faktura VAT</h1>
+ {{ end }}
+ <h2>nr. {{ .InvoiceNumber }}</h2>
+ <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><b>NIP:</b> {{ .InvoiceeVAT }}</li>
+ {{ end }}
+
+ {{ if .ReverseVAT }}
+ <li><b>(nie podlega VAT)</b></li>
+ {{ end }}
+ </ul>
+ </div>
+ </div>
+ <div style="clear: both; height: 1em;"></div>
+ <table class="items">
+ <tr>
+ <th style="width: 60%;">Nazwa towaru lub usługi</th>
+ <th>Cena<br />netto</th>
+ <th>Ilość</th>
+ <th>VAT (%)</th>
+ <th>Wartość<br />netto</th>
+ <th>Wartość<br />brutto</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">RAZEM netto</td>
+ <td>{{ .TotalNet }}</td>
+ </tr>
+ <tr>
+ <td colspan="5" class="lhead">VAT{{ if .ReverseVAT }} (nie podlega){{ end }} {{ if .USCustomer }}(nie podlega){{ end }}</td>
+ <td>{{ .VATTotal }}</td>
+ </tr>
+ <tr>
+ <td colspan="5" class="lhead"><b>RAZEM brutto</b></td>
+ <td><b>{{ .Total }}</b></td>
+ </tr>
+ </table>
+ </body>
+</html>