games: gogobee learns to poll Pete for money it has to move

Pete holds the chips; we hold the euros and are the only one who can move
them. This is the loop that turns an escrow row on games.parodia.dev into a
real balance change here.

It is a poll because Pete cannot call us and isn't going to be able to. Every
step is keyed on the escrow guid, because every step can be interrupted in the
worst possible place: die after DebitIdem and before the verdict is queued,
and Pete re-offers the row, we claim it again, the debit replays as a no-op
and reports the same answer. The player is charged once. That is the only
property here that really matters, and there is a test that kills us three
times to prove it.

The verdict rides the queue that already carries adventure facts — it wants
the same durability, backoff and parking, so the row now says where it's going
rather than the queue growing a twin. Flush sends it at once instead of after
a 15s sender tick, because there's a person at the other end watching a
spinner.

First GET gogobee has ever made to Pete.
This commit is contained in:
prosolis
2026-07-13 23:00:11 -07:00
parent ab2bcf0c59
commit 6d402343e6
16 changed files with 5051 additions and 15 deletions

View File

@@ -457,6 +457,11 @@ func runMigrations(d *sql.DB) error {
// message arrives once; a poll loop whose ack is lost on the wire will
// retry, and without this the player pays twice. See euro.DebitIdem.
`ALTER TABLE euro_transactions ADD COLUMN external_id TEXT`,
// Pete games — the outbound queue carries more than adventure facts now.
// An escrow verdict goes to a different Pete endpoint, but it wants the
// same durability, backoff and parking, so it rides the same queue and the
// row says where it's going. Existing rows are all facts, hence the default.
`ALTER TABLE pete_emit_queue ADD COLUMN path TEXT NOT NULL DEFAULT '/api/ingest/adventure'`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {

View File

@@ -20,6 +20,7 @@ import (
"net/http"
"os"
"strings"
"sync"
"time"
"gogobee/internal/db"
@@ -62,12 +63,18 @@ type Config struct {
// so emit hooks scattered across plugins (and free functions like
// markAdventureDead) can call Emit without threading a handle through.
type Client struct {
cfg Config
http *http.Client
cfg Config
http *http.Client
draining sync.Mutex // one drain at a time; see drain
}
var std *Client
// factPath is where an adventure fact goes. Every queue row carries its own
// destination now, because escrow verdicts ride the same queue to a different
// endpoint.
const factPath = "/api/ingest/adventure"
// Tuning for the background sender.
const (
senderTick = 15 * time.Second
@@ -118,11 +125,20 @@ func Emit(f Fact) {
slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err)
return
}
// OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped.
enqueue(f.GUID, factPath, payload)
}
// enqueue puts one payload on the durable queue, addressed to a Pete endpoint.
//
// OR IGNORE gives GUID-idempotency: a re-emit of the same key is dropped. That
// is the whole safety story for money — an escrow verdict is queued under its
// escrow guid, so a verdict can never be enqueued twice and can never be
// delivered as two different answers.
func enqueue(guid, path string, payload []byte) {
db.Exec("pete emit enqueue",
`INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at)
VALUES (?, ?, unixepoch(), 0, 0)`,
f.GUID, string(payload))
`INSERT OR IGNORE INTO pete_emit_queue (guid, path, payload, created_at, attempts, next_attempt_at)
VALUES (?, ?, ?, unixepoch(), 0, 0)`,
guid, path, string(payload))
}
// StartSender launches the background drain loop. It runs until ctx is
@@ -147,10 +163,33 @@ func StartSender(ctx context.Context) {
}()
}
// Flush drains the queue right now instead of waiting for the next tick.
//
// The escrow loop needs this. A player who clicked "buy chips" is watching a
// spinner, and a verdict that sat in the queue for a 15-second sender tick would
// make the whole border feel broken even though nothing is. Durability is not
// weakened: the row is written first and only then sent, exactly as the ticker
// does it.
func Flush(ctx context.Context) {
if std == nil || !std.cfg.Enabled {
return
}
std.drain(ctx)
}
// drain sends up to senderBatch due rows, one at a time.
//
// Serialized: the ticker and Flush can both call this, and two drains racing
// would send the same row twice. Every Pete endpoint we push to is idempotent,
// so that would be survivable rather than harmful — but it would also mean an
// escrow verdict arriving twice as a matter of routine, and "harmless in theory"
// is not how the money path should be run.
func (c *Client) drain(ctx context.Context) {
c.draining.Lock()
defer c.draining.Unlock()
rows, err := db.Get().Query(
`SELECT guid, payload FROM pete_emit_queue
`SELECT guid, path, payload FROM pete_emit_queue
WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch()
ORDER BY created_at LIMIT ?`,
maxAttempts, senderBatch)
@@ -158,11 +197,11 @@ func (c *Client) drain(ctx context.Context) {
slog.Error("peteclient: drain query", "err", err)
return
}
type item struct{ guid, payload string }
type item struct{ guid, path, payload string }
var batch []item
for rows.Next() {
var it item
if err := rows.Scan(&it.guid, &it.payload); err != nil {
if err := rows.Scan(&it.guid, &it.path, &it.payload); err != nil {
slog.Error("peteclient: drain scan", "err", err)
continue
}
@@ -174,7 +213,7 @@ func (c *Client) drain(ctx context.Context) {
if ctx.Err() != nil {
return
}
if err := c.send(ctx, []byte(it.payload)); err != nil {
if err := c.post(ctx, it.path, []byte(it.payload)); err != nil {
if ctx.Err() != nil {
// Shutdown canceled the in-flight send — Pete didn't reject
// anything. Don't burn a durable retry attempt; the row is picked
@@ -235,12 +274,8 @@ func PushRoster(ctx context.Context, snap RosterSnapshot) error {
return std.post(ctx, "/api/ingest/roster", payload)
}
// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the
// post sends one payload to a Pete endpoint with bearer auth. Mirrors the
// bearer-POST pattern in email_nag.go:sendCode.
func (c *Client) send(ctx context.Context, payload []byte) error {
return c.post(ctx, "/api/ingest/adventure", payload)
}
func (c *Client) post(ctx context.Context, path string, payload []byte) error {
url := c.cfg.IngestURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
@@ -261,6 +296,139 @@ func (c *Client) post(ctx context.Context, path string, payload []byte) error {
return nil
}
// ---------------------------------------------------------------------------
// The euro/chip border
//
// Pete holds chips; we hold the euros. A player buying in or cashing out opens
// an escrow row on Pete, and we are the only one who can move the money for it —
// Pete has no route into this box's network and is not getting one. So we poll.
//
// This is the first GET gogobee has ever made to Pete. Everything else in this
// package is us pushing facts outward; here we are asking for work.
//
// The escrow guid is the idempotency key end to end: it names the row on Pete,
// it is the external_id on our euro transaction, and it is the queue key of the
// verdict we push back. That is what makes every step here safe to retry, which
// matters because every step here can be interrupted between moving real money
// and saying so.
// ---------------------------------------------------------------------------
// Escrow is one pending crossing, as Pete describes it. Amounts are whole euros:
// chips are 1:1 and there is no sub-unit to lose.
type Escrow struct {
GUID string `json:"guid"`
MatrixUser string `json:"matrix_user"`
Kind string `json:"kind"` // "buyin" | "cashout"
Amount int64 `json:"amount"`
State string `json:"state"`
}
// EscrowVerdict is our answer: did the euros move, and what is the balance now.
// A rejected buy-in carries the reason, which Pete shows the player.
type EscrowVerdict struct {
GUID string `json:"guid"`
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
BalanceAfter float64 `json:"balance_after"`
}
const escrowVerdictPath = "/api/games/escrow/settled"
// PendingEscrow asks Pete for crossings waiting on us. Includes rows we claimed
// but never answered — if we died holding one, the player's money is stranded
// until we pick it up again.
func PendingEscrow(ctx context.Context) ([]Escrow, error) {
if !Enabled() {
return nil, nil
}
var out []Escrow
if err := std.getJSON(ctx, "/api/games/escrow/pending", &out); err != nil {
return nil, err
}
return out, nil
}
// ClaimEscrow tells Pete we are taking a row, and returns the row as Pete now
// holds it. Move the money against *this*, not against the copy from the poll:
// the claim is the moment the amount and the player are fixed.
//
// A row Pete has already decided comes back in a terminal state rather than
// "claimed". That is not an error — it means the work is done, and it is exactly
// what stops a settled cash-out from being paid a second time.
func ClaimEscrow(ctx context.Context, guid string) (Escrow, error) {
var e Escrow
payload, err := json.Marshal(map[string]string{"guid": guid})
if err != nil {
return e, err
}
if err := std.postJSON(ctx, "/api/games/escrow/claim", payload, &e); err != nil {
return e, err
}
return e, nil
}
// EmitEscrowVerdict durably queues our answer and returns immediately. Keyed on
// the escrow guid, so a verdict is enqueued once and only once, and the sender's
// retry/backoff/parking machinery carries it the rest of the way.
//
// The caller should Flush after this: a player is watching a spinner.
func EmitEscrowVerdict(v EscrowVerdict) {
if !Enabled() {
return
}
payload, err := json.Marshal(v)
if err != nil {
slog.Error("peteclient: marshal escrow verdict", "guid", v.GUID, "err", err)
return
}
// Namespaced so an escrow guid can never collide with a fact guid in the
// queue's primary key. Fact guids are "<event_type>:<token>:<ts>"; escrow
// guids are random. A collision would be a lost verdict, so don't rely on
// luck for it.
enqueue("escrow:"+v.GUID, escrowVerdictPath, payload)
}
// getJSON does a bearer-authed GET and decodes the body.
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
return c.do(req, out)
}
// postJSON does a bearer-authed POST and decodes the body. Distinct from post,
// which is the fire-and-forget path the queue uses and ignores the response.
func (c *Client) postJSON(ctx context.Context, path string, payload []byte, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.IngestURL+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
req.Header.Set("Content-Type", "application/json")
return c.do(req, out)
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("pete %s status %d: %s", req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("pete %s: decode: %w", req.URL.Path, err)
}
return nil
}
// backoffSec computes the retry delay for a row. It re-reads the current attempt
// count so the delay grows geometrically without needing it passed in.
func backoffSec(guid string) int {

View File

@@ -0,0 +1,165 @@
package plugin
import (
"context"
"log/slog"
"time"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// The casino's money loop.
//
// Pete runs the games and holds the chips; we hold the euros and are the only
// one who can move them. A player who buys in or cashes out on games.parodia.dev
// opens an escrow row over there, and this loop is what turns it into a real
// balance change over here.
//
// It has to be a poll because Pete cannot call us — there is no inbound API on
// this box and there isn't going to be one. That constraint is what shaped the
// whole design: money crosses the border twice per *session*, not twice per
// hand, so a few seconds of poll latency lands on "buying chips…" and never on
// "deal me in".
//
// Every step is idempotent on the escrow guid, because every step can be
// interrupted in the worst possible place. If we die after DebitIdem and before
// the verdict is queued, Pete re-offers the row, we claim it again, DebitIdem
// replays as a no-op and reports the same answer, and the verdict goes out. The
// player is charged once. That is the only property here that really matters.
const (
// escrowPollInterval — a player is watching a spinner, so this is seconds,
// not the 30s the mischief seam gets away with.
escrowPollInterval = 3 * time.Second
// escrowPollTimeout — one full poll-claim-settle pass. Generous: the
// alternative to finishing is a claimed row nobody answers.
escrowPollTimeout = 30 * time.Second
reasonInsufficientFunds = "insufficient_funds"
)
// StartPeteEscrowLoop runs the border forever. Safe to call when the Pete seam
// is off — it simply never starts.
func StartPeteEscrowLoop(ctx context.Context, euro *EuroPlugin) {
if !peteclient.Enabled() || euro == nil {
return
}
slog.Info("pete games: escrow loop started", "interval", escrowPollInterval)
go func() {
t := time.NewTicker(escrowPollInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pollEscrowOnce(ctx, euro)
}
}
}()
}
// escrowPollOK tracks the poll's health so we log transitions and not a line
// every three seconds. Pete being down is normal during its redeploys and is not
// worth shouting about; Pete being down for an hour is.
var escrowPollOK = true
func pollEscrowOnce(ctx context.Context, euro *EuroPlugin) {
ctx, cancel := context.WithTimeout(ctx, escrowPollTimeout)
defer cancel()
pending, err := peteclient.PendingEscrow(ctx)
if err != nil {
if escrowPollOK {
slog.Warn("pete games: escrow poll failed — buy-ins and cash-outs are stalled", "err", err)
escrowPollOK = false
}
return
}
if !escrowPollOK {
slog.Info("pete games: escrow poll recovered")
escrowPollOK = true
}
if len(pending) == 0 {
return
}
for _, e := range pending {
if ctx.Err() != nil {
return
}
settleEscrow(ctx, euro, e)
}
// The verdicts are on the durable queue now. Send them at once rather than
// waiting up to a sender tick — there is a person at the other end of this.
peteclient.Flush(ctx)
}
// settleEscrow moves the euros for one crossing and queues the answer.
func settleEscrow(ctx context.Context, euro *EuroPlugin, e peteclient.Escrow) {
// Claim first. The claim is what fixes the amount and the player: the poll's
// copy of the row is already a few milliseconds stale, and this is money.
claimed, err := peteclient.ClaimEscrow(ctx, e.GUID)
if err != nil {
slog.Warn("pete games: claim failed, will retry", "guid", e.GUID, "err", err)
return
}
// Pete has already decided this one — a stale re-offer that raced our own
// earlier verdict. Nothing to do, and nothing went wrong.
if claimed.State != "claimed" {
slog.Debug("pete games: escrow already decided", "guid", e.GUID, "state", claimed.State)
return
}
if claimed.Amount <= 0 {
slog.Error("pete games: escrow with a non-positive amount, refusing",
"guid", claimed.GUID, "amount", claimed.Amount)
return
}
user := id.UserID(claimed.MatrixUser)
amount := float64(claimed.Amount)
var ok bool
var balance float64
switch claimed.Kind {
case "buyin":
ok, balance, err = euro.DebitIdem(user, amount, "games_buyin", claimed.GUID)
case "cashout":
ok, balance, err = euro.CreditIdem(user, amount, "games_cashout", claimed.GUID)
default:
// Not a transient failure and not something a retry fixes. Say no, so the
// row reaches a terminal state on Pete instead of being re-offered forever.
slog.Error("pete games: unknown escrow kind", "guid", claimed.GUID, "kind", claimed.Kind)
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
GUID: claimed.GUID, OK: false, Reason: "unknown_kind",
})
return
}
if err != nil {
// The money did not move and we don't know that it didn't — so say
// nothing. Pete re-offers the row once the claim goes stale, and the guid
// makes the retry safe whichever way this actually landed.
slog.Error("pete games: euro move failed, leaving the row for a retry",
"guid", claimed.GUID, "kind", claimed.Kind, "err", err)
return
}
reason := ""
if !ok {
// The only way DebitIdem says no: the player cannot cover it. A cash-out
// has no floor to breach, so this is always a buy-in.
reason = reasonInsufficientFunds
}
peteclient.EmitEscrowVerdict(peteclient.EscrowVerdict{
GUID: claimed.GUID,
OK: ok,
Reason: reason,
BalanceAfter: balance,
})
slog.Info("pete games: escrow moved", "guid", claimed.GUID, "user", claimed.MatrixUser,
"kind", claimed.Kind, "amount", claimed.Amount, "ok", ok, "balance_after", balance)
}

View File

@@ -0,0 +1,222 @@
package plugin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// fakePete is Pete's half of the escrow wire: it offers rows, lets us claim
// them, and records the verdicts we push back. Enough to drive the loop end to
// end without either real box.
type fakePete struct {
mu sync.Mutex
pending []peteclient.Escrow
claimed map[string]bool
verdicts []peteclient.EscrowVerdict
srv *httptest.Server
}
func newFakePete(t *testing.T, token string, rows ...peteclient.Escrow) *fakePete {
t.Helper()
f := &fakePete{pending: rows, claimed: map[string]bool{}}
mux := http.NewServeMux()
auth := func(r *http.Request) bool { return r.Header.Get("Authorization") == "Bearer "+token }
mux.HandleFunc("GET /api/games/escrow/pending", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
f.mu.Lock()
defer f.mu.Unlock()
_ = json.NewEncoder(w).Encode(f.pending)
})
mux.HandleFunc("POST /api/games/escrow/claim", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
var req struct{ GUID string }
_ = json.NewDecoder(r.Body).Decode(&req)
f.mu.Lock()
defer f.mu.Unlock()
for _, e := range f.pending {
if e.GUID != req.GUID {
continue
}
f.claimed[e.GUID] = true
e.State = "claimed"
_ = json.NewEncoder(w).Encode(e)
return
}
w.WriteHeader(404)
})
mux.HandleFunc("POST /api/games/escrow/settled", func(w http.ResponseWriter, r *http.Request) {
if !auth(r) {
w.WriteHeader(401)
return
}
var v peteclient.EscrowVerdict
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
w.WriteHeader(400)
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.verdicts = append(f.verdicts, v)
w.WriteHeader(200)
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
// wire points the peteclient singleton at the fake and turns the seam on.
func (f *fakePete) wire(t *testing.T, token string) {
t.Helper()
t.Setenv("PETE_INGEST_URL", f.srv.URL)
t.Setenv("PETE_INGEST_TOKEN", token)
t.Setenv("FEATURE_PETE_NEWS", "true")
peteclient.Init()
}
func (f *fakePete) got() []peteclient.EscrowVerdict {
f.mu.Lock()
defer f.mu.Unlock()
return append([]peteclient.EscrowVerdict(nil), f.verdicts...)
}
// TestEscrowLoopBuyInDebitsOnceAndReportsIt is the happy path across the border:
// Pete offers a buy-in, we take the euros, and the verdict lands back on Pete
// carrying the balance the player now has.
func TestEscrowLoopBuyInDebitsOnceAndReportsIt(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@reala:parodia.dev")
seedBalance(t, euro, user, 1000)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-1", MatrixUser: string(user), Kind: "buyin", Amount: 400, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 600 {
t.Fatalf("balance = %v, want 600 (1000 less a 400 buy-in)", got)
}
v := pete.got()
if len(v) != 1 || !v[0].OK || v[0].GUID != "esc-1" {
t.Fatalf("verdicts = %+v, want one ok verdict for esc-1", v)
}
if v[0].BalanceAfter != 600 {
t.Fatalf("balance_after = %v, want 600", v[0].BalanceAfter)
}
}
// TestEscrowLoopReofferedRowDebitsOnce is the failure this whole design is built
// around. We move the euros, then die before the verdict is delivered. Pete
// re-offers the row. The player must not pay twice.
func TestEscrowLoopReofferedRowDebitsOnce(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@twice:parodia.dev")
seedBalance(t, euro, user, 1000)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-dup", MatrixUser: string(user), Kind: "buyin", Amount: 250, State: "requested",
})
pete.wire(t, "tok")
// Three passes over a row Pete keeps offering, as it would after a crash.
for i := 0; i < 3; i++ {
pollEscrowOnce(context.Background(), euro)
}
if got := euro.GetBalance(user); got != 750 {
t.Fatalf("balance = %v, want 750 — the re-offered row debited more than once", got)
}
// The verdict is queued under the escrow guid, so it is enqueued once no
// matter how many times we decide it. Pete's settle is idempotent anyway,
// but the queue is the first line of that defence.
if v := pete.got(); len(v) != 1 {
t.Fatalf("delivered %d verdicts for one row, want 1: %+v", len(v), v)
}
}
// TestEscrowLoopBrokePlayerIsRejectedCleanly — the debit is refused. Nothing may
// move, and Pete has to be told why, because the player is staring at a spinner
// that needs to become a sentence.
func TestEscrowLoopBrokePlayerIsRejectedCleanly(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@broke:parodia.dev")
seedBalance(t, euro, user, 10)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-broke", MatrixUser: string(user), Kind: "buyin", Amount: 5000, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 10 {
t.Fatalf("balance = %v, want 10 untouched", got)
}
v := pete.got()
if len(v) != 1 || v[0].OK || v[0].Reason != reasonInsufficientFunds {
t.Fatalf("verdicts = %+v, want one rejection carrying insufficient_funds", v)
}
}
// TestEscrowLoopCashOutCredits — the way out of the casino.
func TestEscrowLoopCashOutCredits(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@winner:parodia.dev")
seedBalance(t, euro, user, 100)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-out", MatrixUser: string(user), Kind: "cashout", Amount: 900, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 1000 {
t.Fatalf("balance = %v, want 1000", got)
}
if v := pete.got(); len(v) != 1 || !v[0].OK || v[0].BalanceAfter != 1000 {
t.Fatalf("verdicts = %+v, want one ok cash-out at 1000", v)
}
}
// TestEscrowLoopUnknownKindIsRefusedNotRetried — a row we can't interpret. A
// silent skip would leave it re-offered forever, so say no and let it reach a
// terminal state on Pete.
func TestEscrowLoopUnknownKindIsRefusedNotRetried(t *testing.T) {
euro := newEuroTestDB(t)
user := id.UserID("@weird:parodia.dev")
seedBalance(t, euro, user, 500)
pete := newFakePete(t, "tok", peteclient.Escrow{
GUID: "esc-weird", MatrixUser: string(user), Kind: "tribute", Amount: 100, State: "requested",
})
pete.wire(t, "tok")
pollEscrowOnce(context.Background(), euro)
if got := euro.GetBalance(user); got != 500 {
t.Fatalf("balance = %v, want 500 untouched", got)
}
v := pete.got()
if len(v) != 1 || v[0].OK || v[0].Reason != "unknown_kind" {
t.Fatalf("verdicts = %+v, want one refusal", v)
}
}