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
- An hourly `invoice-collection` job charges every open invoice created in the last 24 hours through the provider's `POST /v1/charges`, one invoice at a time, and marks it paid with the provider's charge id. An invoice whose charge fails is logged and stays open for the next hourly run. A cancelled run stops before the next invoice; the job returns an error (and pages) only when the open invoices cannot be listed or a cancellation stopped it before an invoice it had not started; an invoice whose charge fails because of the cancellation is logged like any other failure. Invoices older than 24 hours are left to the existing dunning job.
- A charge request that ends without any response from the provider (the 10 second client timeout, a refused or dropped connection) is retried, up to 3 attempts in all, with a pause of 1 s and then 2 s. A response with an error status, or a 2xx response whose body cannot be read, is not retried and fails that invoice for this run.
- The provider creates one charge per `POST /v1/charges` unless the request carries an `Idempotency-Key` header (any string up to 255 characters). A key the provider has seen in the 7 days since its first request returns that first response unchanged and creates nothing; a key whose first request was answered with an error status is not remembered. A response with an error status means the provider created no charge. The `description` is free text shown on the customer's statement and is not used to deduplicate. The provider may create a charge and still answer after our timeout.
- Each invoice is charged at most once, however many attempts the retry loop or later hourly runs make for it.
Files touched
- internal/payments/client.go
- internal/billing/collect.go
- cmd/worker/jobs.go
--- 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},
+ }
+}
+