From 2190de8f2e2274465a95d2fba3cade70623ade2d Mon Sep 17 00:00:00 2001 From: Bharat Kunwar Date: Wed, 8 Jul 2026 13:46:55 +0100 Subject: [PATCH] feat: add payslip download command Downloads payslip PDFs as /YYYY-MM.pdf. With no args it fills the gaps in --dir; naming months forces a re-download. Raw response bytes are exposed to commands via a *[]byte out param in the client. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VkbrnPieXm8EsJPgnybzvp --- cmd/zeltapp/client.go | 6 ++++ cmd/zeltapp/finance.go | 68 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/cmd/zeltapp/client.go b/cmd/zeltapp/client.go index a1f5484..d40cfb3 100644 --- a/cmd/zeltapp/client.go +++ b/cmd/zeltapp/client.go @@ -415,6 +415,12 @@ func (c *client) dispatch(method, path string, bodyBytes []byte, out any, retry } } if out != nil && len(rb) > 0 { + // *[]byte callers get the raw body (e.g. PDF downloads), everyone + // else gets JSON decoding. + if b, ok := out.(*[]byte); ok { + *b = rb + return nil + } return json.Unmarshal(rb, out) } return nil diff --git a/cmd/zeltapp/finance.go b/cmd/zeltapp/finance.go index 1044aa4..c75d0d7 100644 --- a/cmd/zeltapp/finance.go +++ b/cmd/zeltapp/finance.go @@ -1,9 +1,12 @@ package main import ( + "bytes" "encoding/json" "fmt" "net/url" + "os" + "path/filepath" "github.com/spf13/cobra" ) @@ -24,6 +27,71 @@ func payslipCmd() *cobra.Command { fmt.Sprintf("/apiv2/users/%d/payslips", id), } })) + cmd.AddCommand(payslipDownloadCmd()) + return cmd +} + +func payslipDownloadCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "download [YYYY-MM ...]", + Aliases: []string{"dl"}, + Short: "Download payslip PDFs as /YYYY-MM.pdf (no args: all months missing from --dir)", + RunE: func(cmd *cobra.Command, args []string) error { + user, _ := cmd.Flags().GetString("user") + dir, _ := cmd.Flags().GetString("dir") + wanted := map[string]bool{} + for _, a := range args { + wanted[a] = true + } + return withClient(func(c *client) error { + id, err := resolveUserOrSelf(c, user) + if err != nil { + return err + } + var slips []struct { + EntryID string `json:"entryId"` + EntryType string `json:"entryType"` + PaymentDate string `json:"paymentDate"` + } + if err := c.do("GET", fmt.Sprintf("/apiv2/users/%d/payslips", id), nil, &slips); err != nil { + return err + } + downloaded := 0 + for _, s := range slips { + if s.EntryType != "Payslip" || s.EntryID == "" || len(s.PaymentDate) < 7 { + continue + } + month := s.PaymentDate[:7] + if len(wanted) > 0 && !wanted[month] { + continue + } + out := filepath.Join(dir, month+".pdf") + if _, err := os.Stat(out); err == nil && len(wanted) == 0 { + continue // no-args mode only fills gaps; name a month to re-download + } + var pdf []byte + p := fmt.Sprintf("/apiv2/payroll/payruns/%s/%d/payslip-pdf/download", s.EntryID, id) + if err := c.do("GET", p, nil, &pdf); err != nil { + return err + } + if !bytes.HasPrefix(pdf, []byte("%PDF")) { + return fmt.Errorf("%s: response is not a PDF (%d bytes)", month, len(pdf)) + } + if err := writeFileAtomic(out, pdf, 0o644); err != nil { + return err + } + fmt.Println(out) + downloaded++ + } + if downloaded == 0 { + fmt.Fprintln(stderr, "nothing to download") + } + return nil + }) + }, + } + cmd.Flags().String("user", "me", "userId, email, or 'me'") + cmd.Flags().String("dir", ".", "directory to save PDFs into") return cmd }