Documentation
¶
Overview ¶
Package testclock owns the TestClock resource: a tenant-scoped frozen-time simulator used to walk test-mode subscriptions through full billing lifecycles (trials, cycles, dunning retries) in compressed wall-clock time.
Clocks exist only in test mode — the underlying table and its CHECK constraint enforce livemode=false. Live-mode callers cannot create, advance, or attach clocks; attempting to reach the endpoints from a live key produces a 400 from the service guard before the DB even sees the write.
Index ¶
- Constants
- Variables
- func NewCatchupQueue(buffer int) *chanCatchupQueue
- type AdvanceInput
- type AuditWriter
- type BillingRunner
- type CatchupJob
- type CatchupQueue
- type CatchupRunner
- type CatchupWorker
- type ClawbackRetrier
- type CreateInput
- type CreditExpirer
- type CustomerReader
- type DunningProcessor
- type Handler
- type PauseResumer
- type PostgresStore
- func (s *PostgresStore) CompleteAdvance(ctx context.Context, tenantID, id string) (domain.TestClock, error)
- func (s *PostgresStore) Create(ctx context.Context, tenantID string, clk domain.TestClock) (domain.TestClock, error)
- func (s *PostgresStore) Delete(ctx context.Context, tenantID, id string) error
- func (s *PostgresStore) Get(ctx context.Context, tenantID, id string) (domain.TestClock, error)
- func (s *PostgresStore) List(ctx context.Context, tenantID string) ([]domain.TestClock, error)
- func (s *PostgresStore) ListAllAdvancing(ctx context.Context) ([]domain.TestClock, error)
- func (s *PostgresStore) ListSubscriptionsOnClock(ctx context.Context, tenantID, clockID string) ([]domain.Subscription, error)
- func (s *PostgresStore) MarkAdvancing(ctx context.Context, tenantID, id string, newFrozenTime time.Time) (domain.TestClock, error)
- func (s *PostgresStore) MarkFailed(ctx context.Context, tenantID, id, reason string) (domain.TestClock, error)
- func (s *PostgresStore) ResolveSim(ctx context.Context, tenantID, id string) (clock.Sim, bool, error)
- func (s *PostgresStore) RetryFromFailed(ctx context.Context, tenantID, id string) (domain.TestClock, error)
- func (s *PostgresStore) SaveAdvanceSummary(ctx context.Context, tenantID, id string, summaryJSON []byte) error
- type Service
- func (s *Service) Advance(ctx context.Context, tenantID, id string, input AdvanceInput) (domain.TestClock, error)
- func (s *Service) Create(ctx context.Context, tenantID string, input CreateInput) (domain.TestClock, error)
- func (s *Service) Delete(ctx context.Context, tenantID, id string) error
- func (s *Service) Get(ctx context.Context, tenantID, id string) (domain.TestClock, error)
- func (s *Service) List(ctx context.Context, tenantID string) ([]domain.TestClock, error)
- func (s *Service) ListAttachedCustomers(ctx context.Context, tenantID, clockID string) ([]domain.Customer, error)
- func (s *Service) ListSubscriptions(ctx context.Context, tenantID, clockID string) ([]domain.Subscription, error)
- func (s *Service) RecoverInFlight(ctx context.Context) error
- func (s *Service) RetryAdvance(ctx context.Context, tenantID, id string) (domain.TestClock, error)
- func (s *Service) RunCatchup(ctx context.Context, job CatchupJob) (err error)
- func (s *Service) SetBillingRunner(b BillingRunner)
- func (s *Service) SetCatchupQueue(q CatchupQueue)
- func (s *Service) SetClawbackRetrier(c ClawbackRetrier)
- func (s *Service) SetCreditExpirer(c CreditExpirer)
- func (s *Service) SetCustomerReader(r CustomerReader)
- func (s *Service) SetDunning(d DunningProcessor)
- func (s *Service) SetPauseResumer(r PauseResumer)
- func (s *Service) SetTaxRetrier(t TaxRetrier)
- func (s *Service) SetTrialExpirer(t TrialExpirer)
- type Store
- type TaxRetrier
- type TrialExpirer
Constants ¶
const CatchupTimeout = 10 * time.Minute
CatchupTimeout caps the wall-clock duration of a single advance catchup. Defends against pathological data shapes (e.g. an operator advancing 10 years on a daily-billed sub) that would otherwise tie up a worker indefinitely. On timeout the worker flips the clock to internal_failure so the operator can inspect-and-delete to recover.
Variables ¶
var ErrQueueFull = errors.New("test-clock catchup queue is full")
ErrQueueFull is returned by Enqueue when the buffered channel is at capacity. Callers translate to 503 Service Unavailable.
Functions ¶
func NewCatchupQueue ¶
func NewCatchupQueue(buffer int) *chanCatchupQueue
NewCatchupQueue returns a queue with the given buffer size. Buffer should be larger than max-expected concurrent in-flight advances; 100 is generous.
Types ¶
type AdvanceInput ¶
type AuditWriter ¶
type AuditWriter interface {
Log(ctx context.Context, tenantID, action, resourceType, resourceID, resourceLabel string, metadata map[string]any) error
}
AuditWriter is the narrow audit surface testclock uses.
type BillingRunner ¶
type BillingRunner interface {
RunCycleForClock(ctx context.Context, tenantID, clockID string, batchSize int) (int, []error)
// RetryPendingChargesForClock retries auto-charge attempts on
// clock-pinned invoices flagged auto_charge_pending=true. Called
// during catchup AFTER period generation, so customers who attached
// a payment method since the last advance get charged as part of
// THIS advance, not on a future wall-clock tick. ADR-029 Phase 1.
RetryPendingChargesForClock(ctx context.Context, tenantID, clockID string, limit int) (int, []error)
// EnrollStalledForDunningForClock routes clock-pinned, card-less
// auto_charge_pending invoices into dunning during catchup, so a
// simulated subscription with no payment method reaches a terminal
// (pause/cancel/uncollectible) under test clocks — mirroring the
// wall-clock scheduler's no-payment enrollment step. Runs after the
// charge retry; StartDunning is idempotent.
EnrollStalledForDunningForClock(ctx context.Context, tenantID, clockID string, limit int) (int, []error)
// ScanThresholdsForClock fires hard-cap (Stripe-parity billing-
// thresholds) invoices for clock-pinned subs whose running cycle
// subtotal has crossed a configured threshold. Runs during catchup
// after period generation; without it, the wall-clock cron would
// (incorrectly) fire threshold invoices on clock-pinned subs.
// ADR-029 Phase 3.
ScanThresholdsForClock(ctx context.Context, tenantID, clockID string, batchSize int) (int, []error)
}
BillingRunner is the narrow hook the service uses to drive a billing catchup after a clock advance. In production billing.Engine satisfies it (via RunCycleForClock); tests can stub with a spy that records calls.
Post-ADR-028 disjoint flows: the catchup uses RunCycleForClock, which scopes to subs pinned to ONE clock. The wall-clock cron's generic RunCycle is a different code path and never touches clock-pinned subs. This separation removes the SKIP-LOCKED race and the drip-bill artifact that the dual-flow design produced.
The per-sub period loop in billSubscription handles multi-year catchup internally — one call per sub processes all due periods.
type CatchupJob ¶
type CatchupJob struct {
TenantID string
ClockID string
// PrevFrozenTime is the clock's frozen_time BEFORE this advance (the
// simulated "from" instant), captured by Advance before MarkAdvancing
// overwrites it. RunCatchup records it as the advance summary's
// AdvancedFrom so the dashboard can show the span the advance covered.
// Zero on the recover-in-flight path (the pre-advance value is no longer
// available after a restart); the summary then shows only the destination.
PrevFrozenTime time.Time
}
CatchupJob is the unit of work the async worker processes — one clock's worth of post-advance billing catchup. Carries tenant + clock identity; the worker reloads frozen_time and the sub list inside its own ctx (RLS-scoped per-tenant).
type CatchupQueue ¶
type CatchupQueue interface {
Enqueue(job CatchupJob) error
}
CatchupQueue is the narrow contract the service uses to dispatch catchup work. Behind a small interface so the in-process channel implementation here can be swapped for a Redis/durable queue later without touching the service.
Enqueue is non-blocking but bounded — if the buffer is full, the caller gets an error rather than blocking the request handler. At expected operator volumes (one operator clicking Advance, not a load test) the buffer never fills; on full it surfaces as a 503 to the caller, which is the right shape compared to a hung request handler.
type CatchupRunner ¶
type CatchupRunner func(ctx context.Context, job CatchupJob) error
CatchupRunner is the function the worker calls per job. The service satisfies it via Service.RunCatchup. Kept as a function type to avoid a method-on-struct dependency cycle and to make the worker easy to stub in tests.
type CatchupWorker ¶
type CatchupWorker struct {
// contains filtered or unexported fields
}
CatchupWorker drains the queue and runs catchup for each job. Single-goroutine on purpose — concurrent catchups on the same tenant fight for the same RLS partition and the same DB rows, so serialising is simpler and there's no per-clock isolation that parallelism would buy us at expected operator volumes.
func NewCatchupWorker ¶
func NewCatchupWorker(queue *chanCatchupQueue, runner CatchupRunner) *CatchupWorker
NewCatchupWorker wires a worker around a queue + runner. Caller must call Start to begin draining; Stop to shut down cleanly.
func (*CatchupWorker) Start ¶
func (w *CatchupWorker) Start()
Start spawns the drain goroutine. Idempotent — repeat calls are no-ops (sync.Once-guarded) so a misconfigured boot path can't produce two competing workers.
type ClawbackRetrier ¶ added in v0.2.0
type ClawbackRetrier interface {
RetryPendingClawbackIssueForClock(ctx context.Context, tenantID, clockID string, frozenTime time.Time, batch int) (int, []error)
}
ClawbackRetrier is the narrow hook the catchup orchestrator uses to issue a clock's DEFERRED simulated clawback drafts — a downgrade clawback whose source invoice was in-flight (ADR-059 defer) when created and has since settled. Implemented by *creditnote.Service via RetryPendingClawbackIssueForClock. Takes frozenTime so Issue() stamps the credit note and its ledger / tax effects in simulated time. ADR-029 disjoint flows.
type CreateInput ¶
type CreditExpirer ¶
type CreditExpirer interface {
ExpireCreditsForClock(ctx context.Context, tenantID, clockID string, frozenTime time.Time) (int, []error)
}
CreditExpirer is the narrow hook the catchup orchestrator uses to expire grants belonging to clock-pinned customers. ADR-029 Phase 4. Takes frozenTime explicitly so the per-grant `expires_at < now` comparison happens in simulated time, not wall-clock.
type CustomerReader ¶
type CustomerReader interface {
ListByTestClockID(ctx context.Context, tenantID, clockID string) ([]domain.Customer, error)
}
CustomerReader is the narrow hook the service uses to list customers pinned to a clock. The customer domain owns the customers table — including the encrypt-at-rest wrapper around display_name / email — so testclock reads through this interface instead of joining the table directly. That way every caller of the "attached customers" surface gets the same decrypted view that every other read path (customer detail page, customer list, etc.) does.
Per-domain rule (CLAUDE.md): zero cross-domain imports between peer packages; coordination via narrow interfaces only.
type DunningProcessor ¶
type DunningProcessor interface {
ProcessDueRunsForClock(ctx context.Context, tenantID, clockID string, frozenTime time.Time, limit int) (int, []error)
}
DunningProcessor is the narrow hook the catchup orchestrator uses to advance dunning state for clock-pinned invoices. ADR-029 Phase 5. Takes frozenTime explicitly so the per-run next_action_at compare runs in simulated time.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler exposes /v1/test-clocks. Auth middleware upstream enforces that the caller holds a test-mode key (livemode=false); the service itself adds a second guard so a misconfigured auth chain can't slip live writes through.
func NewHandler ¶
func (*Handler) SetAuditLogger ¶
func (h *Handler) SetAuditLogger(a AuditWriter)
SetAuditLogger wires audit on test-clock lifecycle. Critical for forensics: "was this invoice generated by a real cycle or by an operator advancing a sim clock?" is answered by cross-referencing the clock advance audit row with the invoice's issued_at.
type PauseResumer ¶
type PauseResumer interface {
ProcessExpiredPauseCollectionsForClock(ctx context.Context, tenantID, clockID string, frozenTime time.Time) (int, []error)
}
PauseResumer is the narrow hook the catchup orchestrator uses to auto-resume subs whose pause_collection_resumes_at has elapsed. Implemented by *subscription.Service via ProcessExpiredPauseCollectionsForClock. Runs as Phase 0.7 (after trial expiry, before cycle billing) so a sub whose pause expires inside this Advance window unpauses BEFORE the cycle scan evaluates it — giving Stripe-parity "resume at resumes_at" semantics. Without this phase, the auto-resume only fires inside billOnePeriod, which is silent on subs whose next_billing_at is still in the future.
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
func NewPostgresStore ¶
func NewPostgresStore(db *postgres.DB) *PostgresStore
func (*PostgresStore) CompleteAdvance ¶
func (s *PostgresStore) CompleteAdvance(ctx context.Context, tenantID, id string) (domain.TestClock, error)
CompleteAdvance flips advancing → ready and clears any prior last_failure_reason. The clear matters when the operator retried a failed advance and it succeeded — the dashboard shouldn't keep showing yesterday's error.
func (*PostgresStore) Delete ¶
func (s *PostgresStore) Delete(ctx context.Context, tenantID, id string) error
Delete tears down a test clock and its ENTIRE simulated customer graph in one transaction (ADR-086, Design B — complete teardown, supersedes ADR-016's soft-delete + detach). After it runs no simulated row survives, so no wall-clock plane (auto-charge, dunning, usage-aggregation, credit-expiry, analytics) can ever act on one — the class the ADR dissolves.
Safety: see clockTeardownStatements — every DELETE is keyed transitively on `test_clock_id = $clock`, and a test clock is always livemode=false, so no live-mode row is reachable; the clock row's own DELETE adds `livemode=false` as belt. Runs under TxTenant (RLS confines it to the tenant).
Idempotent: re-deleting a gone clock returns errs.ErrNotFound.
func (*PostgresStore) ListAllAdvancing ¶
ListAllAdvancing scans test_clocks for rows in status='advancing' across every tenant. Used at boot to recover catchup jobs that were in-flight when the previous process exited. RLS-bypassed (TxBypass) because the recovery path runs before any tenant ctx is established and needs to see all tenants. Limited to 1000 to bound the recovery enqueue burst — at expected pre-launch volumes there will rarely be more than 0-1 stuck clocks; the limit is a sanity cap, not a paging target.
func (*PostgresStore) ListSubscriptionsOnClock ¶
func (s *PostgresStore) ListSubscriptionsOnClock(ctx context.Context, tenantID, clockID string) ([]domain.Subscription, error)
ListSubscriptionsOnClock returns subscriptions attached to the given clock. This crosses into the subscription table on purpose — the test clock owns the catchup orchestration and needs the sub rows to drive it. We keep the query narrow (id + fields the catchup consults) to avoid duplicating the subscription package's full scan.
func (*PostgresStore) MarkAdvancing ¶
func (*PostgresStore) MarkFailed ¶
func (s *PostgresStore) MarkFailed(ctx context.Context, tenantID, id, reason string) (domain.TestClock, error)
MarkFailed flips advancing → internal_failure and persists the reason. Caller truncates to ~500 chars; full payload stays in structured slog.
func (*PostgresStore) ResolveSim ¶ added in v0.2.0
func (s *PostgresStore) ResolveSim(ctx context.Context, tenantID, id string) (clock.Sim, bool, error)
ResolveSim answers "is this clock attached-able, and what instant does it stand at?" in ONE read — used by the customer service at customer-create time, which must both validate the pin and bind the new customer's audit row onto the sim axis (ADR-090 §5).
It returns the pair from a single Get precisely so the two halves cannot drift: an "exists?" bool would send the caller back for the instant, and a clock id paired with a separately-fetched instant is the half-truth clock.Sim is designed to make unrepresentable.
ErrNotFound → (zero, false, nil); other errors propagate. Soft-deleted clocks count as not-existing.
func (*PostgresStore) RetryFromFailed ¶
func (s *PostgresStore) RetryFromFailed(ctx context.Context, tenantID, id string) (domain.TestClock, error)
RetryFromFailed flips internal_failure → advancing, clearing the failure reason. Frozen_time stays at its current value — the catchup loop is idempotent on subs whose next_billing_at has already passed it. ADR-018.
func (*PostgresStore) SaveAdvanceSummary ¶
func (s *PostgresStore) SaveAdvanceSummary(ctx context.Context, tenantID, id string, summaryJSON []byte) error
SaveAdvanceSummary persists the per-phase counts the catchup produced onto the clock row. Written by RunCatchup just before the advancing → ready / internal_failure transition, so the transition's RETURNING clause reads it back. A plain column write (not gated on status): the catchup is the only writer and the clock is 'advancing' at this point. Best-effort relative to the transition — if the process dies between the two writes the clock simply shows no summary, which is strictly better than blocking the transition.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service provides the test-clock API surface. Depends on Store for persistence, optionally BillingRunner to drive the billing engine during catchup, and optionally CatchupQueue to dispatch catchup asynchronously after Advance. When the queue is wired, Advance returns as soon as the clock is marked advancing — a worker picks up the job and runs the catchup off the request path. When the queue is nil (narrow unit tests), Advance runs catchup inline so tests can assert end-state synchronously.
func NewService ¶
func (*Service) Advance ¶
func (s *Service) Advance(ctx context.Context, tenantID, id string, input AdvanceInput) (domain.TestClock, error)
Advance moves the clock forward to FrozenTime and dispatches a billing catchup for every subscription attached to it. Catchup runs the billing engine in a loop because a large jump (e.g. 3 months forward on a monthly sub) closes multiple cycles — each engine sweep processes the cycles that are now due, advances next_billing_at, and the next sweep picks up the following cycle.
Async dispatch: when SetCatchupQueue has been wired (the production path), Advance returns as soon as the clock is marked advancing and a CatchupJob has been enqueued. A worker picks up the job, runs the catchup, and flips the clock to ready / internal_failure when done. The dashboard polls /v1/test-clocks/{id} every 1.5s while status === 'advancing' to surface the transition. This matches Stripe's Test Clocks shape — the HTTP advance call returns in milliseconds, the catchup runs in the background.
Sync fallback: when the queue is nil (narrow unit tests), Advance runs the catchup inline. RunCatchup contains the same logic the worker calls.
State machine:
ready ──Advance── advancing ──catchup ok── ready
│
└──catchup errored── internal_failure
While in advancing, other callers get 409 from the MarkAdvancing CAS; while in internal_failure, all further advances are blocked until the tenant inspects and deletes the clock.
func (*Service) ListAttachedCustomers ¶
func (s *Service) ListAttachedCustomers(ctx context.Context, tenantID, clockID string) ([]domain.Customer, error)
ListAttachedCustomers returns customers pinned to this clock — the Stripe-parity "attached customers" surface (ADR-027 Tier 3). 404s if the clock doesn't exist. Reads through the customer domain's CustomerReader so display_name / email arrive decrypted; see CustomerReader doc.
func (*Service) ListSubscriptions ¶
func (s *Service) ListSubscriptions(ctx context.Context, tenantID, clockID string) ([]domain.Subscription, error)
ListSubscriptions returns the subscriptions pinned to the given clock. Verifies the clock exists first so a missing-clock id surfaces as 404 rather than an empty list (which would look like an empty clock).
func (*Service) RecoverInFlight ¶
RecoverInFlight scans for clocks left in status='advancing' from a prior process — typically because the server restarted while a catchup was running — and re-enqueues them. Idempotent: runCatchupLoop only processes subs with next_billing_at <= frozen_time, so resuming partial work just continues from where it stopped.
Called once on boot AFTER the worker is wired. If the queue is nil (test path), this is a no-op.
func (*Service) RetryAdvance ¶
RetryAdvance resumes a clock parked in status='internal_failure' from a prior catchup error. Stripe-parity recovery — the catchup loop is idempotent (only processes subs with next_billing_at <= frozen_time), so resuming from where the previous attempt stopped is safe. Frozen_time stays at its current value; the operator's earlier Advance input is preserved by virtue of MarkAdvancing already having stamped frozen_time before the failure. ADR-018.
Async dispatch: same as Advance — when SetCatchupQueue is wired, returns as soon as the clock is back in 'advancing' and a CatchupJob is enqueued. Worker drains; dashboard polls.
Refuses to retry from any state other than internal_failure with a 409. A clock currently in 'advancing' has a worker already running on it; a clock in 'ready' has no failure to retry.
func (*Service) RunCatchup ¶
func (s *Service) RunCatchup(ctx context.Context, job CatchupJob) (err error)
RunCatchup is the worker's entry point. The "real work" — looping over due periods per sub — lives in the billing engine (Engine.billSubscription, ADR-028). RunCatchup is a thin orchestrator: it calls RunCycle once, then flips the clock state from advancing → ready (success) or → internal_failure (error) with the failure reason captured for the dashboard banner.
CatchupTimeout (10 min, set on the worker's ctx) bounds the total operation. The engine's per-sub safety counter (maxPeriodsPerSubPerCall) is the inner ceiling.
func (*Service) SetBillingRunner ¶
func (s *Service) SetBillingRunner(b BillingRunner)
SetBillingRunner wires the billing catchup hook. Kept as a setter rather than a constructor arg because the billing engine is built after the testclock service in router.go — the engine and service form a small dependency cycle (engine reads clocks, clock advance runs engine) that we break by deferred injection.
func (*Service) SetCatchupQueue ¶
func (s *Service) SetCatchupQueue(q CatchupQueue)
SetCatchupQueue wires the async dispatch path. Production code always sets this; unit tests that want sync behaviour leave it nil so Advance runs catchup inline.
func (*Service) SetClawbackRetrier ¶ added in v0.2.0
func (s *Service) SetClawbackRetrier(c ClawbackRetrier)
SetClawbackRetrier wires the per-clock clawback-issue phase (ADR-029). When nil, catchup skips it and a deferred simulated clawback waits for a later advance that has the wiring; production wires the creditnote Service. Runs after the charge phase so a source invoice that just settled this advance is terminal by the time the deferred clawback re-drives.
func (*Service) SetCreditExpirer ¶
func (s *Service) SetCreditExpirer(c CreditExpirer)
SetCreditExpirer wires the per-clock credit-expiry phase of catchup. Optional — when nil, catchup skips Phase 4 (credit expiry). The cron's ListExpiredGrants would have processed those rows pre- ADR-029, so nil here means clock-pinned grants don't expire until an operator advances the clock; for narrow unit tests that don't exercise credits, nil is fine. Production wires the real credit service. ADR-029 Phase 4.
func (*Service) SetCustomerReader ¶
func (s *Service) SetCustomerReader(r CustomerReader)
SetCustomerReader wires the customer-domain hook used to list attached customers. Late-bound (not a constructor arg) because the customer service and the testclock service are built in the same router-wiring step; passing a nil reader is fine for narrow unit tests that don't exercise the attached-customers surface, but the production producer always sets a real reader.
func (*Service) SetDunning ¶
func (s *Service) SetDunning(d DunningProcessor)
SetDunning wires the per-clock dunning-advance phase of catchup. Optional — when nil, catchup skips Phase 5 (dunning advance) for clock-pinned invoices. The cron's ProcessDueRuns excludes them already, so nil here means clock-pinned dunning state freezes until an operator advances; for narrow tests without dunning fixtures, that's fine. ADR-029 Phase 5.
func (*Service) SetPauseResumer ¶
func (s *Service) SetPauseResumer(r PauseResumer)
SetPauseResumer wires the per-clock pause-resume phase (Phase 0.7). Optional — without it, clock-pinned subs whose pause_collection_resumes_at has elapsed stay paused until a cycle happens to be due (the engine's defensive in-cycle gate). Production wires the subscription Service so resume-at-resumes_at parity with Stripe holds across all subs, not just those with imminent cycles.
func (*Service) SetTaxRetrier ¶
func (s *Service) SetTaxRetrier(t TaxRetrier)
SetTaxRetrier wires the per-clock tax-retry phase of catchup. Optional — when nil, catchup skips Phase 2 (tax retry); the cron's ListPendingTaxRetry would have processed those rows pre-ADR-029, so nil here means clock-pinned tax retries simply don't fire (callers without the wiring are typically narrow unit tests). Production always wires this with the real invoice service. ADR-029 Phase 2.
func (*Service) SetTrialExpirer ¶
func (s *Service) SetTrialExpirer(t TrialExpirer)
SetTrialExpirer wires the per-clock trial-expiry phase (Phase 0.5). Optional — narrow unit tests skip it; production wires the subscription Service. Without this, a clock advance past trial_end_at leaves the sub's status='trialing' until the next cycle close (Bug #8 regression).
type Store ¶
type Store interface {
Create(ctx context.Context, tenantID string, clk domain.TestClock) (domain.TestClock, error)
Get(ctx context.Context, tenantID, id string) (domain.TestClock, error)
List(ctx context.Context, tenantID string) ([]domain.TestClock, error)
Delete(ctx context.Context, tenantID, id string) error
// MarkAdvancing flips status ready → advancing and simultaneously sets
// the new frozen_time, atomically. The new frozen_time becomes visible
// immediately — this is what lets the billing-catchup loop find subs
// on the clock as "due". Returns errs.InvalidState when the clock is
// not currently ready (prevents overlapping advances).
//
// The new frozen_time must be >= the current frozen_time; the service
// enforces that up-front so we don't ship a regressed-time clock.
MarkAdvancing(ctx context.Context, tenantID, id string, newFrozenTime time.Time) (domain.TestClock, error)
// CompleteAdvance flips status advancing → ready. Pair with MarkAdvancing
// at the end of a successful catchup run. Clears any
// last_failure_reason from a prior failed-then-retried advance.
CompleteAdvance(ctx context.Context, tenantID, id string) (domain.TestClock, error)
// SaveAdvanceSummary persists the per-phase counts the catchup produced
// (domain.AdvanceSummary as JSON) onto the clock row. Called by RunCatchup
// just before the advancing → ready / internal_failure transition so the
// transition reads it back into the returned clock.
SaveAdvanceSummary(ctx context.Context, tenantID, id string, summaryJSON []byte) error
// MarkFailed flips advancing → internal_failure when a catchup run
// errors. The reason is persisted on the clock row so the dashboard
// can show "Catchup failed: <reason>" without forcing the operator
// to dig through server logs. Truncated by the caller to ~500
// chars; the full payload stays in structured slog. ADR-018.
MarkFailed(ctx context.Context, tenantID, id, reason string) (domain.TestClock, error)
// RetryFromFailed transitions internal_failure → advancing on a
// clock the operator has chosen to retry. Frozen_time is unchanged
// — the catchup loop only processes subs whose next_billing_at <=
// frozen_time, so resuming from where the previous attempt
// stopped is idempotent. The caller then enqueues a fresh
// CatchupJob; the worker drains it like any other advance.
// ADR-018.
RetryFromFailed(ctx context.Context, tenantID, id string) (domain.TestClock, error)
// ListSubscriptionsOnClock returns every sub attached to the clock. Used
// by the service during advance to drive the billing catchup. RLS scopes
// the result to the tenant already; clock-ID filter narrows further.
ListSubscriptionsOnClock(ctx context.Context, tenantID, clockID string) ([]domain.Subscription, error)
// ListAllAdvancing returns every clock currently in status='advancing'
// across ALL tenants. Used at boot to recover catchup jobs that were
// in-flight when the previous process exited (server restart, deploy,
// crash). RLS-bypassed because it needs to surface clocks for tenants
// the caller isn't scoped to. The recovery path then re-enqueues each
// onto the catchup queue and the worker resumes them.
ListAllAdvancing(ctx context.Context) ([]domain.TestClock, error)
}
Store is the persistence contract for TestClock rows. Kept narrow: create, read, list, atomic status transitions, atomic advance, delete. All RLS- scoped via postgres.TxTenant in the PostgresStore.
type TaxRetrier ¶
type TaxRetrier interface {
RetryPendingTaxForClock(ctx context.Context, tenantID, clockID string, batch int) (int, []error)
}
TaxRetrier is the narrow hook the catchup orchestrator uses to drive the per-clock tax-retry phase. Implemented by *invoice.Service via RetryPendingTaxForClock. ADR-029 Phase 2.
Returns (processed_count, per-row errors) — same shape as every other phase callback so the orchestrator can collect failures uniformly.
type TrialExpirer ¶
type TrialExpirer interface {
ProcessExpiredTrialsForClock(ctx context.Context, tenantID, clockID string, frozenTime time.Time) (int, []error)
}
TrialExpirer is the narrow hook the catchup orchestrator uses to flip trialing subs whose `trial_end_at` has elapsed in sim time to active — at `trial_end_at`, not at the later cycle close. Implemented by *subscription.Service via ProcessExpiredTrialsForClock. Runs as Phase 0.5 (before cycle billing) so by the time Phase 1 reads the sub list, the status field reflects the actual lifecycle state. Without this phase, status stays 'trialing' for the gap between trial_end_at and the first chargeable cycle close (up to ~30 days for calendar billing).