mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Auto-reload charges the org's card with nobody present, so the rule is its admins' to set — and a SuperAdmin's or a service's. The amount is a top-up's own range ($5 to $5,000) and the balance it reloads below is $0 to $5,000, refused with the range in the sentence. The sweep asks the processor to charge one org at most three times a UTC day, a decline included, and names the limit in the row it skips. The day's reload is reserved under compare-and-swap before the card is charged, against the rule as it stands, so a rule switched off mid-sweep is not charged or re-armed, and overlapping sweeps share the limit. A charge refused before the processor was asked, or a replay of one already counted, gives its reload back. An admin's save swaps the rule's own fields and keeps the day's count. Saving, changing, choosing the default of and removing the org's cards, and charging its card on file, are its admins' too. A member whose wallet is their own person account still manages that wallet's cards. A loaded rule is bound to its key, so an edit is the rule on file. Before, an update wrote a second, parentless row the next read never found. The first read now removes such a row, and honours it when it switched the rule off; it never arms a rule or raises what it charges. Co-authored-by: Hanzo Dev <[email protected]>
91 lines
3.2 KiB
Go
91 lines
3.2 KiB
Go
package billing
|
|
|
|
// The auto-recharge wire, asserted as BYTES.
|
|
//
|
|
// GET /v1/billing/recharge answers in two shapes that differ by one key: with no
|
|
// row it omits lastRechargedAt entirely, and with a row it includes it even when
|
|
// the org has never been recharged and the value is empty. That is the quirk the
|
|
// view's Stored field exists to carry, and a test reading the response as a
|
|
// decoded map cannot see it — an absent key and an empty string both arrive as
|
|
// the zero value. Sorted map keys are load-bearing for the same reason: the
|
|
// handler renders through a map because encoding/json sorts them, so serialising
|
|
// the view struct instead would reorder every field of a response clients have
|
|
// parsed since it shipped. Both facts are only visible in the bytes.
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/models/autorecharge"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// readRecharge drives GET /v1/billing/recharge for org against ctx's datastore
|
|
// and returns the response body verbatim.
|
|
func readRecharge(t *testing.T, org *organization.Organization, ctx context.Context) string {
|
|
t.Helper()
|
|
seed := func(c *zip.Ctx) {
|
|
c.Locals("organization", org)
|
|
c.SetContext(ctx)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/billing/recharge", nil)
|
|
resp := driveSeeded(seed, "/v1/billing/recharge", req, GetAutoRecharge)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// TestReadAutoRechargeWire_NoRowOmitsLastRecharged: an org that never set the
|
|
// rule reads as the disabled one — and the response carries NO lastRechargedAt
|
|
// key at all.
|
|
func TestReadAutoRechargeWire_NoRowOmitsLastRecharged(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("recharge-norow")
|
|
|
|
got := readRecharge(t, org, ctx)
|
|
want := `{"amountCents":0,"currency":"usd","enabled":false,"perDay":3,"thresholdCents":0,"userId":"recharge-norow"}`
|
|
if got != want {
|
|
t.Fatalf("no-row body:\n got %s\nwant %s", got, want)
|
|
}
|
|
}
|
|
|
|
// TestReadAutoRechargeWire_StoredRowKeepsEmptyLastRecharged: a stored row that
|
|
// has never fired carries lastRechargedAt as an EMPTY STRING. The key's presence
|
|
// is the whole point — it is how a reader knows the rule was configured, and it
|
|
// is what would vanish if the absent-row default were reused for a real row.
|
|
func TestReadAutoRechargeWire_StoredRowKeepsEmptyLastRecharged(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
org := moneyOrg("recharge-row")
|
|
|
|
db := datastore.New(org.Namespaced(ctx))
|
|
cfg := autorecharge.New(db)
|
|
cfg.UserId = org.Name
|
|
cfg.Enabled = true
|
|
cfg.ThresholdCents = 1000
|
|
cfg.AmountCents = 5000
|
|
cfg.Currency = "usd"
|
|
if err := cfg.Create(); err != nil {
|
|
t.Fatalf("seed auto-recharge row: %v", err)
|
|
}
|
|
|
|
got := readRecharge(t, org, ctx)
|
|
want := `{"amountCents":5000,"currency":"usd","enabled":true,"lastRechargedAt":"","perDay":3,"thresholdCents":1000,"userId":"recharge-row"}`
|
|
if got != want {
|
|
t.Fatalf("stored-row body:\n got %s\nwant %s", got, want)
|
|
}
|
|
}
|