go/svc/invoice: import from code.hackerspace.pl/q3k/inboice
diff --git a/go/svc/invoice/BUILD.bazel b/go/svc/invoice/BUILD.bazel
new file mode 100644
index 0000000..d654bc9
--- /dev/null
+++ b/go/svc/invoice/BUILD.bazel
@@ -0,0 +1,29 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "main.go",
+ "model.go",
+ "render.go",
+ ],
+ importpath = "code.hackerspace.pl/hscloud/go/svc/invoice",
+ visibility = ["//visibility:private"],
+ deps = [
+ "//go/mirko:go_default_library",
+ "//go/svc/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/go/svc/invoice/main.go b/go/svc/invoice/main.go
new file mode 100644
index 0000000..8a5ddbe
--- /dev/null
+++ b/go/svc/invoice/main.go
@@ -0,0 +1,211 @@
+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.Invoice == nil {
+ return nil, status.Error(codes.InvalidArgument, "invoice must be given")
+ }
+ if len(req.Invoice.Item) < 1 {
+ return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one item")
+ }
+ for i, item := range req.Invoice.Item {
+ if item.Title == "" {
+ return nil, status.Errorf(codes.InvalidArgument, "invoice 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)
+ }
+ if item.UnitPrice == 0 {
+ return nil, status.Errorf(codes.InvalidArgument, "invoice 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)
+ }
+ }
+ 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.Invoice.InvoicerBilling) < 1 {
+ return nil, status.Error(codes.InvalidArgument, "invoice must contain at least one line of the invoicer's billing address")
+ }
+ for i, c := range req.Invoice.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.Invoice.InvoicerVatId == "" {
+ return nil, status.Error(codes.InvalidArgument, "invoice must contain invoicer's vat id")
+ }
+
+ uid, err := s.m.createInvoice(ctx, req.Invoice)
+ 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")
+ }
+ 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
+}
+
+func newService(m *model) *service {
+ return &service{
+ m: m,
+ }
+}
+
+func (s *service) RenderInvoice(req *pb.RenderInvoiceRequest, srv pb.Invoicer_RenderInvoiceServer) error {
+ sealed, err := s.m.getSealedUid(srv.Context(), req.Uid)
+ if err != nil {
+ if _, ok := status.FromError(err); ok {
+ return err
+ }
+ glog.Errorf("getSealedUid(_, %q): %v", req.Uid, err)
+ return status.Error(codes.Unavailable, "internal server error")
+ }
+
+ var rendered []byte
+ if sealed != "" {
+ // Invoice is sealed, return stored PDF.
+ rendered, err = s.m.getRendered(srv.Context(), req.Uid)
+ if err != nil {
+ if _, ok := status.FromError(err); ok {
+ return err
+ }
+ glog.Errorf("getRendered(_, %q): %v", req.Uid, err)
+ return status.Error(codes.Unavailable, "internal server error")
+ }
+ } else {
+ // Invoice is proforma, render.
+ invoice, err := s.m.getInvoice(srv.Context(), req.Uid)
+ if err != nil {
+ if _, ok := status.FromError(err); ok {
+ return err
+ }
+ glog.Errorf("getInvoice(_, %q): %v", req.Uid, err)
+ return status.Error(codes.Unavailable, "internal server error")
+ }
+
+ glog.Infof("%+v", invoice)
+ rendered, err = renderInvoicePDF(invoice, "xxxx", true)
+ if err != nil {
+ glog.Errorf("renderProformaPDF(_): %v", 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) {
+ if err := s.m.sealInvoice(ctx, req.Uid); 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)
+ pb.RegisterInvoicerServer(mi.GRPC(), s)
+
+ if err := mi.Serve(); err != nil {
+ glog.Exitf("Serve failed: %v", err)
+ }
+
+ <-mi.Done()
+}
diff --git a/go/svc/invoice/model.go b/go/svc/invoice/model.go
new file mode 100644
index 0000000..0ed8245
--- /dev/null
+++ b/go/svc/invoice/model.go
@@ -0,0 +1,213 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strconv"
+
+ "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,
+ 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,
+ 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 string) 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
+ ) values (
+ ?,
+ ( 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)
+ 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
+ }
+
+ 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
+ }
+
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *model) createInvoice(ctx context.Context, i *pb.Invoice) (string, error) {
+ data, err := proto.Marshal(i)
+ if err != nil {
+ return "", err
+ }
+
+ sql := `
+ insert into invoice (
+ proto
+ ) values (
+ ?
+ )
+ `
+ res, err := m.db.Exec(sql, data)
+ if err != nil {
+ return "", err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return "", err
+ }
+
+ glog.Infof("%+v", id)
+ return fmt.Sprintf("%d", id), 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
+}
+
+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.proto from invoice where 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.NotFound, "no such invoice")
+ }
+ return nil, err
+ }
+
+ p := &pb.Invoice{}
+ if err := proto.Unmarshal(data, p); err != nil {
+ return nil, err
+ }
+ return p, nil
+}
diff --git a/go/svc/invoice/proto/BUILD.bazel b/go/svc/invoice/proto/BUILD.bazel
new file mode 100644
index 0000000..63c82cc
--- /dev/null
+++ b/go/svc/invoice/proto/BUILD.bazel
@@ -0,0 +1,16 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+ name = "go_default_library",
+ srcs = [
+ "generate.go",
+ "inboice.pb.go",
+ ],
+ importpath = "code.hackerspace.pl/hscloud/go/svc/invoice/proto",
+ visibility = ["//visibility:public"],
+ deps = [
+ "@com_github_golang_protobuf//proto:go_default_library",
+ "@org_golang_google_grpc//:go_default_library",
+ "@org_golang_x_net//context:go_default_library",
+ ],
+)
diff --git a/go/svc/invoice/proto/generate.go b/go/svc/invoice/proto/generate.go
new file mode 100644
index 0000000..b0f6618
--- /dev/null
+++ b/go/svc/invoice/proto/generate.go
@@ -0,0 +1,3 @@
+package proto
+
+//go:generate protoc -I.. ../inboice.proto --go_out=plugins=grpc:.
diff --git a/go/svc/invoice/render.go b/go/svc/invoice/render.go
new file mode 100644
index 0000000..47c6c6d
--- /dev/null
+++ b/go/svc/invoice/render.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "time"
+
+ wkhtml "github.com/sebastiaanklippert/go-wkhtmltopdf"
+
+ "code.hackerspace.pl/hscloud/go/svc/invoice/templates"
+ pb "code.hackerspace.pl/hscloud/proto/invoice"
+)
+
+var (
+ invTmpl *template.Template
+)
+
+func init() {
+ a, err := templates.Asset("invoice.html")
+ if err != nil {
+ panic(err)
+ }
+ invTmpl = template.Must(template.New("invoice.html").Parse(string(a)))
+}
+
+func renderInvoicePDF(i *pb.Invoice, number string, proforma bool) ([]byte, error) {
+ now := time.Now()
+
+ 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: 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,
+
+ InvoicerBilling: make([]string, len(i.InvoicerBilling)),
+ InvoiceeBilling: make([]string, len(i.CustomerBilling)),
+ }
+
+ unit := "€"
+
+ for d, s := range i.InvoicerBilling {
+ data.InvoicerBilling[d] = s
+ }
+ for d, s := range i.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))
+
+ totalNet += rowTotalNet
+ total += rowTotal
+ 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),
+ })
+ }
+
+ 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.DeliveryCharge = fmt.Sprintf(unit+"%.2f", float64(0))
+
+ var b bytes.Buffer
+ err := invTmpl.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/go/svc/invoice/templates/BUILD.bazel b/go/svc/invoice/templates/BUILD.bazel
new file mode 100644
index 0000000..7874687
--- /dev/null
+++ b/go/svc/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/go/svc/invoice/templates", # keep
+ visibility = ["//go/svc/invoice:__subpackages__"],
+)
diff --git a/go/svc/invoice/templates/invoice.html b/go/svc/invoice/templates/invoice.html
new file mode 100644
index 0000000..e257a42
--- /dev/null
+++ b/go/svc/invoice/templates/invoice.html
@@ -0,0 +1,185 @@
+<!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;
+}
+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><b>Company Registration Number:</b> {{ .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>VAT Number: {{ .InvoiceeVAT }}</li>
+ {{ end }}
+
+ {{ if .ReverseVAT }}
+ <li><b>(reverse charge applies)</b></li>
+ {{ end }}
+ </ul>
+ </div>
+ </div>
+ <div style="clear: both; height: 1em;"></div>
+ <table class="items">
+ <tr>
+ <th style="width: 60%;">Description</th>
+ <th>Price<br/>(ex. VAT)</th>
+ <th>Qty</th>
+ <th>VAT rate</th>
+ <th>Total<br />(net)</th>
+ <th>Total<br />(inc. VAT)</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">Subtotal without VAT</td>
+ <td>{{ .TotalNet }}</td>
+ </tr>
+ <tr>
+ <td colspan="5" class="lhead">VAT Total{{ if .ReverseVAT }} (reverse charge applies){{ end }} {{ if .USCustomer }}(VAT zero rate){{ end }}</td>
+ <td>{{ .VATTotal }}</td>
+ </tr>
+ <tr>
+ <td colspan="5" class="lhead"><b>Total</b></td>
+ <td>{{ .Total }}</td>
+ </tr>
+ </table>
+ </body>
+</html>