From fa818da7c6ebb957a0470078fc7ead4602f05b76 Mon Sep 17 00:00:00 2001 From: Serge Bazanski Date: Thu, 6 May 2021 00:12:53 +0200 Subject: [PATCH] bgpwtf/invoice: add recurrent billing tool Change-Id: Ic3cc03d7b04304ae8c7aa76d8bb889ae8c144838 --- bgpwtf/invoice/proto/BUILD.bazel | 5 +- bgpwtf/invoice/proto/recurrent.proto | 50 ++++++++ bgpwtf/invoice/recurrent/BUILD.bazel | 21 ++++ bgpwtf/invoice/recurrent/main.go | 175 +++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 bgpwtf/invoice/proto/recurrent.proto create mode 100644 bgpwtf/invoice/recurrent/BUILD.bazel create mode 100644 bgpwtf/invoice/recurrent/main.go diff --git a/bgpwtf/invoice/proto/BUILD.bazel b/bgpwtf/invoice/proto/BUILD.bazel index 2eeae644..a1a70331 100644 --- a/bgpwtf/invoice/proto/BUILD.bazel +++ b/bgpwtf/invoice/proto/BUILD.bazel @@ -4,7 +4,10 @@ load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library") proto_library( name = "proto_proto", - srcs = ["invoice.proto"], + srcs = [ + "invoice.proto", + "recurrent.proto", + ], visibility = ["//visibility:public"], ) diff --git a/bgpwtf/invoice/proto/recurrent.proto b/bgpwtf/invoice/proto/recurrent.proto new file mode 100644 index 00000000..1f4ac66e --- /dev/null +++ b/bgpwtf/invoice/proto/recurrent.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; +package invoice; +option go_package = "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto"; + +import "bgpwtf/invoice/proto/invoice.proto"; + +// Subscription is the subscription to a service for which we want to generate +// monthly (at least for now) invoices. +message Subscription { + // Template is the data that will be used to emit the invoice. It will be + // used verbatim in a CreateInvoice request, apart from the following + // changes: + // - if 'date' is not set, the current date will be substituted instead + // - for every item in the invoice, any %Y and %M value in its title will + // be replaced by the year and month of the billing cycle. The billing + // cycle is defined in relation to the date in the Cycle enum below.. + InvoiceData template = 1; + + // Cycle defines the billing cycle policy for this subscription. + enum Cycle { + CYCLE_INVALID = 0; + // The subscription is billed for the month that it is invoiced for. + // Eg., if the invoice has a date of April 1st, April 15th or April + // 30th, the %M in title will be replaced with 04. + // + // This is used for subscriptions that are invoiced a month in advance, + // with invoices being sent out in the beginning of the month. + // + // In the future, the meaning of this enum value might change to 'bill + // at beginning of month/cycle', but currently we only bill once per + // month. + CYCLE_CURRENT = 1; + // The subscription is billed for the month from when it was invoiced. + // Eg., if the invoice has a date of April 1st, April 15th or April + // 30th, the %M in the title will be replaced with 03. + // This is used for subscriptions that are invoiced right after a month + // ends. + // In the future, the meaning of this enum value might change to 'bill + // at end of month/cycle', but currently we only bill once per month. + CYCLE_PREV = 2; + } + Cycle cycle = 2; +} + +// Configuration is a prototext defining subscriptions. Currently it's read +// from a file by //bgpwtf/invoice/recurrent. In The future this might be +// broken up into a database schema. +message Configuration { + repeated Subscription subscription = 1; +} diff --git a/bgpwtf/invoice/recurrent/BUILD.bazel b/bgpwtf/invoice/recurrent/BUILD.bazel new file mode 100644 index 00000000..b9fc578e --- /dev/null +++ b/bgpwtf/invoice/recurrent/BUILD.bazel @@ -0,0 +1,21 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") + +go_library( + name = "go_default_library", + srcs = ["main.go"], + importpath = "code.hackerspace.pl/hscloud/bgpwtf/invoice/recurrent", + visibility = ["//visibility:private"], + deps = [ + "//bgpwtf/invoice/proto:go_default_library", + "//go/pki:go_default_library", + "@com_github_golang_glog//:go_default_library", + "@com_github_golang_protobuf//proto:go_default_library", + "@org_golang_google_grpc//:go_default_library", + ], +) + +go_binary( + name = "recurrent", + embed = [":go_default_library"], + visibility = ["//visibility:public"], +) diff --git a/bgpwtf/invoice/recurrent/main.go b/bgpwtf/invoice/recurrent/main.go new file mode 100644 index 00000000..84e5622c --- /dev/null +++ b/bgpwtf/invoice/recurrent/main.go @@ -0,0 +1,175 @@ +package main + +// recurrent is a tool to bill recurrent monthly invoices. It should be run at +// the beginning of each month against a database of customers stored as a +// prototext. +// +// This is a fairly janky tool, and should be replaced by a proper billing +// service. +// +// $ bazel run //bgpwtf/invoice/recurrent -- \ +// -invoice_configuration=$(pwd)bgpwtf/invoice/customers.pb.text \ +// -invoice_service 10.78.253.10:4200 -hspki_disable +// +// q3k has the sqlite database for the invoice service and the customer +// prototext. + +import ( + "bufio" + "context" + "flag" + "fmt" + "io" + "io/ioutil" + "os" + "strings" + "time" + + "github.com/golang/glog" + "github.com/golang/protobuf/proto" + "google.golang.org/grpc" + + "code.hackerspace.pl/hscloud/go/pki" + + pb "code.hackerspace.pl/hscloud/bgpwtf/invoice/proto" +) + +func init() { + flag.Set("logtostderr", "true") +} + +var ( + flagConfiguration string + flagService string +) + +func main() { + flag.StringVar(&flagService, "invoice_service", "127.0.0.1:4200", "Address of invoice service") + flag.StringVar(&flagConfiguration, "invoice_configuration", "customers.pb.text", "Prototext of customer data") + flag.Parse() + + if flagConfiguration == "" { + glog.Exit("-invoice_configuration must be set") + } + cfgBytes, err := ioutil.ReadFile(flagConfiguration) + if err != nil { + glog.Exitf("could not read configuration: %v", err) + } + + var cfg pb.Configuration + if err := proto.UnmarshalText(string(cfgBytes), &cfg); err != nil { + glog.Exitf("UnmarshalText: %v", err) + } + + conn, err := grpc.Dial(flagService, pki.WithClientHSPKI()) + if err != nil { + glog.Exitf("Dial(%q): %v", flagService, err) + return + } + svc := pb.NewInvoicerClient(conn) + ctx := context.Background() + + var created []string + now := time.Now() + for _, sub := range cfg.Subscription { + glog.Infof("Emitting for %q...", sub.Template.CustomerBilling[0]) + + data := sub.Template + if data.Date == 0 { + data.Date = now.UnixNano() + } + + date := time.Unix(0, data.Date) + year := int(date.Year()) + month := int(date.Month()) + switch sub.Cycle { + case pb.Subscription_CYCLE_CURRENT: + case pb.Subscription_CYCLE_PREV: + month -= 1 + if month < 1 { + month = 12 + year -= 1 + } + default: + glog.Exitf("Invalid cycle: %v", sub.Cycle) + } + + for _, item := range data.Item { + item.Title = strings.ReplaceAll(item.Title, "%M", fmt.Sprintf("%02d", month)) + item.Title = strings.ReplaceAll(item.Title, "%Y", fmt.Sprintf("%04d", year)) + } + res, err := svc.CreateInvoice(ctx, &pb.CreateInvoiceRequest{ + InvoiceData: data, + }) + if err != nil { + glog.Exitf("CreateInvoice: %v", err) + } + glog.Infof("Created invoice %q", res.Uid) + created = append(created, res.Uid) + } + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Invoices generated. Seal? [Yn]") + text, err := reader.ReadString('\n') + if err != nil { + glog.Exitf("Response: %v", err) + } + switch strings.TrimSpace(strings.ToLower(text)) { + case "", "y": + default: + glog.Exitf("Aborting.") + } + for _, uid := range created { + glog.Infof("Sealing %q...", uid) + _, err := svc.SealInvoice(ctx, &pb.SealInvoiceRequest{ + Uid: uid, + DateSource: pb.SealInvoiceRequest_DATE_SOURCE_PROFORMA, + Language: "pl", + }) + if err != nil { + glog.Errorf("Sealing %q failed: %v", uid, err) + continue + } + res, err := svc.GetInvoice(ctx, &pb.GetInvoiceRequest{ + Uid: uid, + }) + if err != nil { + glog.Errorf("Retrieving sealed invoice %q failed: %v", uid, err) + continue + } + fuid := res.Invoice.FinalUid + glog.Infof("%q: Final UID: %s", uid, fuid) + stream, err := svc.RenderInvoice(ctx, &pb.RenderInvoiceRequest{ + Uid: uid, + }) + if err != nil { + glog.Errorf("Rendering sealed invoice failed: %v", err) + continue + } + + path := fmt.Sprintf("/tmp/%s.pdf", strings.ReplaceAll(fuid, "/", "")) + glog.Infof("Downloading %s...", path) + f, err := os.Create(path) + if err != nil { + glog.Errorf("Create: %v", err) + continue + } + + for { + block, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + glog.Errorf("Recv: %v", err) + break + } + if _, err := f.Write(block.Data); err != nil { + glog.Errorf("Write: %v", err) + break + } + } + f.Close() + } + +}