Merge "invoice: bump year for new databases"
diff --git a/bgpwtf/invoice/BUILD.bazel b/bgpwtf/invoice/BUILD.bazel
index 900f0b3..950474e 100644
--- a/bgpwtf/invoice/BUILD.bazel
+++ b/bgpwtf/invoice/BUILD.bazel
@@ -1,4 +1,4 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
 
 go_library(
     name = "go_default_library",
@@ -31,3 +31,9 @@
     embed = [":go_default_library"],
     visibility = ["//visibility:public"],
 )
+
+go_test(
+    name = "go_default_test",
+    srcs = ["calc_test.go"],
+    embed = [":go_default_library"],
+)
diff --git a/bgpwtf/invoice/calc.go b/bgpwtf/invoice/calc.go
index 9c411da..72933d3 100644
--- a/bgpwtf/invoice/calc.go
+++ b/bgpwtf/invoice/calc.go
@@ -1,17 +1,23 @@
 package main
 
 import (
+	"sort"
 	"time"
 
 	pb "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto"
 )
 
+// calculateInvoiceData applies all business logic to populate an Invoice's
+// denormalized fields from its InvoiceData.
 func calculateInvoiceData(p *pb.Invoice) {
+	// Populate default unit.
+	// TODO(q3k): this really should be done on invoice submit instead.
 	p.Unit = p.Data.Unit
 	if p.Unit == "" {
 		p.Unit = "€"
 	}
 
+	// Calculate totals.
 	p.TotalNet = 0
 	p.Total = 0
 	for _, i := range p.Data.Item {
@@ -24,6 +30,21 @@
 		i.Total = rowTotal
 	}
 
+	// Calculate due date.
 	due := int64(time.Hour*24) * p.Data.DaysDue
 	p.DueDate = time.Unix(0, p.Date).Add(time.Duration(due)).UnixNano()
+
+	// Denormalize Items' GTUCodes into the Invoice's summary GTU codes.
+	codeSet := make(map[pb.GTUCode]bool)
+	for _, item := range p.Data.Item {
+		for _, code := range item.GtuCode {
+			codeSet[code] = true
+		}
+	}
+	var codes []pb.GTUCode
+	for c, _ := range codeSet {
+		codes = append(codes, c)
+	}
+	sort.Slice(codes, func(i, j int) bool { return codes[i] < codes[j] })
+	p.GtuCode = codes
 }
diff --git a/bgpwtf/invoice/calc_test.go b/bgpwtf/invoice/calc_test.go
new file mode 100644
index 0000000..e8607c9
--- /dev/null
+++ b/bgpwtf/invoice/calc_test.go
@@ -0,0 +1,129 @@
+package main
+
+import (
+	"testing"
+	"time"
+
+	pb "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto"
+)
+
+// Fake test data for test in this file.
+var (
+	itemInternet1 = &pb.Item{
+		Title:     "Dostęp do Internetu - Umowa FOOBAR/10 - Opłata Abonentowa 2020/08",
+		Count:     1,
+		UnitPrice: 4200,
+		Vat:       23000,
+	}
+	itemInternet2 = &pb.Item{
+		Title:     "Dostęp do Internetu - Umowa FOOBAR/10 - Opłata Abonentowa 2020/09",
+		Count:     1,
+		UnitPrice: 4200,
+		Vat:       23000,
+	}
+	itemHardware = &pb.Item{
+		Title:     "Thinkpad x230, i7, 16GB RAM, Refurbished",
+		Count:     1,
+		UnitPrice: 10000,
+		Vat:       23000,
+		GtuCode:   []pb.GTUCode{pb.GTUCode_GTU_05},
+	}
+	billing1 = []string{
+		"Wykop Sp. z o. o.",
+		"Zakręt 8",
+		"60-351 Poznań",
+	}
+	billing2 = []string{
+		"TEH Adam Karolczak",
+		"Zgoda 18/2",
+		"95-200 Pabianice",
+	}
+	vatID1 = "PL8086133742"
+	vatID2 = "DE133742429"
+	iban   = "PL 59 1090 2402 9746 7956 2256 2375"
+	swift  = "WLPPZLPAXXX"
+)
+
+func TestCalculate(t *testing.T) {
+	now := time.Now()
+	for _, te := range []struct {
+		description string
+		data        *pb.InvoiceData
+		want        *pb.Invoice
+	}{
+		{
+			description: "Invoice without JPK_V7 codes",
+			data: &pb.InvoiceData{
+				Item:            []*pb.Item{itemInternet1, itemInternet2},
+				InvoicerBilling: billing1,
+				CustomerBilling: billing2,
+				InvoicerVatId:   vatID1,
+				CustomerVatId:   vatID2,
+				Date:            now.UnixNano(),
+				DaysDue:         21,
+				Iban:            iban,
+				Swift:           swift,
+				Unit:            "PLN",
+			},
+			want: &pb.Invoice{
+				TotalNet: 8400,
+				Total:    10332,
+				Unit:     "PLN",
+			},
+		},
+		{
+			description: "Invoice with JPK_V7 codes",
+			data: &pb.InvoiceData{
+				// Repeated item with GTU code GTU_5, to ensure result doesn't
+				// have repeated codes.
+				Item:            []*pb.Item{itemInternet1, itemHardware, itemHardware},
+				InvoicerBilling: billing1,
+				CustomerBilling: billing2,
+				InvoicerVatId:   vatID1,
+				CustomerVatId:   vatID2,
+				Date:            now.UnixNano(),
+				DaysDue:         21,
+				Iban:            iban,
+				Swift:           swift,
+				Unit:            "PLN",
+			},
+			want: &pb.Invoice{
+				TotalNet: 24200,
+				Total:    29766,
+				Unit:     "PLN",
+				GtuCode:  []pb.GTUCode{pb.GTUCode_GTU_05},
+			},
+		},
+	} {
+		t.Run(te.description, func(t *testing.T) {
+			invoice := &pb.Invoice{
+				Data: te.data,
+				Date: te.data.Date,
+			}
+			calculateInvoiceData(invoice)
+			if want, got := te.want.TotalNet, invoice.TotalNet; want != got {
+				t.Errorf("got TotalNet %d, wanted %d", got, want)
+			}
+			if want, got := te.want.Total, invoice.Total; want != got {
+				t.Errorf("got Total %d, wanted %d", got, want)
+			}
+			if want, got := te.want.Unit, invoice.Unit; want != got {
+				t.Errorf("got Unit %q, wanted %q", got, want)
+			}
+			due := time.Duration(int64(time.Hour*24) * te.data.DaysDue)
+			if want, got := now.Add(due).UnixNano(), invoice.DueDate; want != got {
+				t.Errorf("got DueDate %d, wanted %d", got, want)
+			}
+			if want, got := len(te.want.GtuCode), len(invoice.GtuCode); want != got {
+				t.Errorf("got %d GTU codes, wanted %d", got, want)
+			} else {
+				for i, want := range te.want.GtuCode {
+					got := invoice.GtuCode[i]
+					if want != got {
+						t.Errorf("GTU code %d: wanted %s, got %s", i, want.String(), got.String())
+					}
+				}
+			}
+		})
+	}
+}
diff --git a/bgpwtf/invoice/main.go b/bgpwtf/invoice/main.go
index 5133010..ae17dbb 100644
--- a/bgpwtf/invoice/main.go
+++ b/bgpwtf/invoice/main.go
@@ -130,6 +130,10 @@
 	return &pb.SealInvoiceResponse{}, nil
 }
 
+func (s *service) GetInvoices(req *pb.GetInvoicesRequest, srv pb.Invoicer_GetInvoicesServer) error {
+	return status.Error(codes.Unimplemented, "unimplemented")
+}
+
 func init() {
 	flag.Set("logtostderr", "true")
 }
diff --git a/bgpwtf/invoice/proto/BUILD.bazel b/bgpwtf/invoice/proto/BUILD.bazel
index 51f85fe..2eeae64 100644
--- a/bgpwtf/invoice/proto/BUILD.bazel
+++ b/bgpwtf/invoice/proto/BUILD.bazel
@@ -1,3 +1,4 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
 load("@io_bazel_rules_go//go:def.bzl", "go_library")
 load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
 
diff --git a/bgpwtf/invoice/proto/invoice.proto b/bgpwtf/invoice/proto/invoice.proto
index ee2b9d8..70e6923 100644
--- a/bgpwtf/invoice/proto/invoice.proto
+++ b/bgpwtf/invoice/proto/invoice.proto
@@ -264,12 +264,12 @@
     // If sealed, otherwise 'proforma'.
     string final_uid = 3;
     int64 date = 4;
-    int64 due_date = 5;
     // Denormalized fields follow.
+    int64 due_date = 5;
     uint64 total_net = 6;
     uint64 total = 7;
     string unit = 8;
-    repeated GTUCode gtu_codes = 10;
+    repeated GTUCode gtu_code = 10;
     // Next tag: 11;
 }
 
@@ -312,9 +312,48 @@
 message SealInvoiceResponse {
 }
 
+message GetInvoicesRequest {
+    // Return all invoices issued in a given year.
+    message ForYear {
+        int32 year = 1;
+    }
+    // Return all invoices issued in a given month of a year.
+    message ForMonth {
+        int32 year = 1;
+        int32 month = 2;
+    }
+
+    oneof range {
+        ForYear for_year = 1;
+        ForMonth for_month = 2;
+    }
+}
+
+message GetInvoicesResponse {
+    // Each chunk may contain an arbitrary amount of invoices, and each
+    // GetInvoices request may return an arbitrary amount of
+    // GetInvoicesResponses in a stream.
+    repeated Invoice invoice = 1;
+}
+
 service Invoicer {
+    // Create an invoice with given data, returning UID. The newly created
+    // invoice is created as a proforma invoice and not yet sealed, ie. not
+    // given a unique, sequential ID.
     rpc CreateInvoice(CreateInvoiceRequest) returns (CreateInvoiceResponse);
+
+    // Get invoice details for a given UID.
     rpc GetInvoice(GetInvoiceRequest) returns (GetInvoiceResponse);
+
+    // Return chunks of a rendered PDF for a given UID. If the invoice is
+    // sealed, the stored PDF will be returned, otherwise a PDF will be
+    // rendered on the fly.
     rpc RenderInvoice(RenderInvoiceRequest) returns (stream RenderInvoiceResponse);
+
+    // Seal invoice, ie. assign it a sequential ID and render it to an
+    // immutable PDF for audit purposes.
     rpc SealInvoice(SealInvoiceRequest) returns (SealInvoiceResponse);
+
+    // Return a summarized detail of invoice data for a given filter.
+    rpc GetInvoices(GetInvoicesRequest) returns (stream GetInvoicesResponse);
 }