Charge open invoices every hour, retry on timeouts

Go · Go · advanced · modification

Adds automatic collection for subscription invoices. Until now finance charged open invoices by hand from the provider dashboard every morning. A new `payments.Client` posts a charge for an invoice's saved payment method and retries when the provider does not answer in time or the connection fails (3 attempts, 1 s and 2 s pauses); error statuses are returned as they are, so a declined card is not retried in a loop. `billing.Collector` runs hourly, charges each open invoice from the last 24 hours and marks it paid with the charge id, logging failures so one bad card does not stop the rest. Ran it against the provider's test mode with a handful of invoices.

Subscription billing worker in Go 1.22 (stdlib `net/http`, `log/slog`). `billing.Store` and `billing.Invoice` exist (unchanged): `OpenSince` returns the unpaid invoices created after the given time, `MarkPaid` records the charge id; `Invoice.ID` is an `int64`. `CustomerID` is the provider's customer id with a saved default payment method, `AmountCents` is a positive amount in the currency's smallest unit (what the provider's `amount` takes), and `Currency` is a lowercase ISO code the provider accepts. Invoices are paid only by this job. The worker's `deps` (unchanged) already carries `Invoices` (the `billing.Store`), `Log`, and `Config.PaymentsURL` / `Config.PaymentsKey`, loaded from the worker's environment. The scheduler runs an hourly job at the top of the hour and never starts a run while the previous one is still going; the worker runs as a single instance. The provider authenticates with `Authorization: Bearer <secret key>`, and a successful charge response always carries a non-empty `id`.

Requirements

Files touched

--- internal/payments/client.go
+package payments
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+const maxAttempts = 3
+
+// Client creates charges through the payment provider's REST API.
+type Client struct {
+	BaseURL string
+	APIKey  string
+	HTTP    *http.Client
+}
+
+func New(baseURL, apiKey string) *Client {
+	return &Client{
+		BaseURL: baseURL,
+		APIKey:  apiKey,
+		HTTP:    &http.Client{Timeout: 10 * time.Second},
+	}
+}
+

Review this PR

Go practice