Support console
Investigate organization access and perform audited, bounded recovery.
The Admin area includes Organizations, Users, and Failures. Platform admins can investigate a selected organization without changing their active organization or impersonating a member. Organization owners and tenant admins have no platform support access. Every support procedure checks the current session and platform role against the database.
Organization investigation
Search organization names, slugs, exact organization IDs, member email fragments,
and linked provider references. Search covers Stripe, Polar, and Creem customer ids
(stripeCustomerId, polarCustomerId, creemCustomerId) and subscription ids for
all three providers (the shared subscription id column). A member email can return several
organizations: choose the intended record explicitly. Searches and lists are
bounded and paginated on the server.
The organization record provides:
- Overview: identity, current owners, effective plan, seats, and explanations derived from the same billing and quota rules that enforce access.
- Members: roles, links to user management, and pending invitations. Expired invitations that remain pending still count under the existing seat policy.
- Billing: local subscription state, periods, trial/cancellation state,
entitlements, the owning billing provider and its references, and the last
recorded local update. A subscription owned by a provider other than the
active one is shown with the
billing_provider_mismatchexplanation. - Usage: response reservations in the exact current quota period, daily UTC aggregates, and the largest recorded user groups. Paid monthly prices use the subscription period; paid yearly prices use the anniversary month inside that period. Baseline access uses the UTC calendar month.
- Events: linked webhook handler outcomes (Stripe, Polar, or Creem) and recorded processing attempts.
- Activity: accepted support actions, reasons, targets, actor IDs, and outcomes. Organization-context user actions retain their global user scope.
The console does not expose prompts, chat messages, private files, credentials, or session tokens. Counts describe retained AI quota reservations, including in-flight work; failed reservations can be refunded. They are not token usage, monetary spend, historical entitlement snapshots, or a complete failure history.
Billing comparison and recovery
Compare is an explicit read against the billing provider that owns the
organization's live subscription (the active provider when there is none). It
retrieves the provider customer and subscriptions using the selected
organization's stored customer mapping (stripeCustomerId, polarCustomerId, or creemCustomerId),
validates the association, and shows a sanitized comparison with its timestamp
and the provider it compared against. Normal support reads never self-heal
billing. An unconfigured provider (provider_not_configured), timeouts, missing
mappings, ambiguous subscriptions, and a subscription owned by a provider other
than the active one (provider_mismatch) are explicit outcomes.
Resync billing requires a reason and a comparison revision. It re-fetches and revalidates current state from the same provider, then uses the canonical subscription write with concurrent-state checks. It can recover a missed local subscription creation when the provider mapping is verified. It never guesses ownership from email or name, and never silently selects among conflicting live subscriptions. Compare and resync work for Stripe, Polar, and Creem.
Possible outcomes include changed, already synchronized, superseded by current state, unavailable, disabled, or invalid/ambiguous mapping. Resync repairs local billing. It does not replay notification effects or mark a failed webhook processed. No refund, quota reset, or provider subscription mutation is part of this workflow.
Plan grants
A platform admin can give an organization a catalog plan without checkout, for
example for a sales-led Enterprise customer. Grant plan takes a paid plan, an
optional end date, and a reason. Revoke grant takes a reason. Both are named
actions: they write the support audit record, the organization audit event
(billing.plan_granted or billing.plan_grant_revoked), and the grant row in
one transaction. The organization audit event omits your reason, because the
organization's own admins read that log.
An organization has at most one unrevoked grant, enforced by a partial unique index. A grant that passed its end date stops applying but still occupies that slot until you revoke it.
getBillingState reports where the plan comes from in source: subscription,
grant, or free. A paying provider subscription always wins over a grant, so
a customer who subscribes keeps the subscription's plan. The grant applies again
if that subscription lapses and the grant is still active. The billing page tells
members "Granted by your account team" while a grant supplies the plan.
Webhook failures
Failures lists failed, stale, and terminal local webhook processing globally. An event without a verified organization association remains visible as unlinked. The status describes the local handler, not the provider's HTTP delivery status. Billing may already be repaired even when a later notification failed.
An eligible Stripe event can be retried by a platform admin after reason capture. The server retrieves the event through Stripe's authenticated API and checks event identity, supported type, account/environment, and resource association. Public webhook signature verification remains mandatory. Browser-supplied payloads are never trusted.
Manual replay is Stripe-only. Polar and Creem expose no authenticated "retrieve event by
id" API, so the adapter declares manualWebhookRetry: false and the console shows
provider_unsupported instead of a retry control for polar and creem events. Recover a
Polar or Creem organization with Compare and Resync billing (they read the live
subscription directly), or run pnpm billing:reconcile for a sweep. Failed Polar and Creem
events stay visible in Failures for investigation.
An event with no verified organization is failed with reason unmatched_organization. Attach to organization is available for failed Stripe events only: it atomically links the event and a missing customer mapping, then replays after checking the retrieved payload's ownership. Repeating the same request returns its recorded result. A rejection before dispatch can undo the association; a failed or interrupted handler stays linked for verification and retry because billing may already have changed.
Polar and Creem cannot re-serve a stored event through an authenticated event API, so attach is disabled and the server refuses it without changing the event or customer mapping. Compare/resync can recover subscription state. For a missed refund or dispute, verify the customer mapping and request redelivery from the provider; resync alone does not recreate a money event. Inspect the outcome before discarding an unmatched event.
A full refund or dispute ends local paid access regardless of webhook delivery order. Partial refunds are audited without ending access. Local revocation happens before provider cancellation; a transient cancellation failure leaves the event failed and retryable. Later subscription events and automatic reconciliation cannot restore that subscription's access. An explicit, verified Resync billing action can restore it when the provider still reports a live subscription. A refund or dispute whose subscription belongs to a different provider or organization than the event does not revoke anything: the event is failed with reason access_revocation_not_applied and an error-level billing drift report names both sides.
Polar allows one customer per email in a Polar organization. A founder who checks out a second SyntaxKit organization with the same billing email gets CONFLICT (polar_customer_conflict). Use a different billing email, or resolve the existing Polar customer, before retrying. This is not a catalog mismatch and billing:reconcile does not create the second Polar customer.
Automatic and manual processing share one event lease and duplicate protection. Each attempt records its origin, operator when applicable, start/end, and sanitized outcome. A superseded worker cannot finalize a newer attempt. Automatic reclamation is capped at five; manual attempts have a separate lifetime budget of three. Neither retry path deletes the event ledger or resets prior counters.
Stripe's recovery API is retention-limited. An unavailable event requires checking the endpoint/provider record and, where appropriate, comparing and resyncing billing separately. See Stripe webhook guidance and processing undelivered events. Manual recovery does not guarantee exactly-once delivery of external email or analytics effects. The existing effect ledger suppresses confirmed duplicates; it is not a general job store and has no generic replay button.
Audit and interruption semantics
Each accepted recovery has a request UUID and durable SupportActionRun, followed
by append-only SupportAuditEvent records at the application boundary. Repeating
the same request returns its recorded result; changing the target, actor, reason,
or execution parameters under that UUID is rejected. Concurrent actions on the
same target are serialized at acceptance.
A run can be running, succeeded, failed, or uncertain. An interrupted run is shown as uncertain after five minutes; elapsed time is not evidence of failure. Use Verify current state for billing and inspect webhook attempts for processing state. An uncertain target stays locked against new actions until its outcome can be established. Resolve verified action creates a separate attributed action and rechecks authoritative state before releasing that lock. Billing must compare in sync with an unchanged local revision. Webhooks need a terminal attempt with no current processing owner. User deletion and session revocation need a recorded end to execution and an action-specific observation; deleted users and revoked sessions must be absent. The original outcome remains uncertain, with the new observation attached; current state does not prove which earlier operation produced it. Uncertain profile, role, ban/unban, and impersonation operations remain available for verification only; they require engineering investigation through the owning auth/session path, because a current-state observation cannot fence a late underlying write. Incomplete auth workers also remain locked. There is no generic force-unlock operation. Do not clear records or replay effects to make the UI proceed. Keep the original run ID when investigating database/persistence interruptions.
Existing user administration requires support metadata for privileged changes, including ban/unban and session revocation. Impersonation remains explicit, records the operator, and uses Better Auth's expiring impersonation session and supported exit flow. Direct HTTP admin mutation paths are disabled in favor of the audited application paths. Opening a user record does not impersonate them.
Setup and operational records
Apply the support_console migration with pnpm db:migrate:deploy, then regenerate
the client with pnpm db:generate. The migration adds action/audit records, webhook
metadata and attempts, and PostgreSQL trigram indexes for support searches. The
migration user needs permission to install pg_trgm (or have it provisioned by the
database administrator). Run pnpm setup:doctor to inspect the active billing
provider's setup. V1.1 adds the billing_provider_boundary migration (a
provider column on subscriptions and webhook events, polarCustomerId on
organizations); see Upgrading.
Existing events keep unknown first-received/provider timestamps and have no fabricated historical attempts. Audit coverage begins when this feature is installed; it is not a complete historical record of all application activity.
Operational records store opaque customer/operator IDs without cascading customer foreign keys, preserving useful non-secret history after deletion. They retain no raw webhook bodies. V1 does not automatically purge audit or action records; buyers should set a retention policy appropriate to their operations and perform reviewed maintenance. Preserve event/effect deduplication records while deliveries or manual recovery remain possible. Deleting a webhook event explicitly also deletes its attempts. No cleanup scheduler or background-job infrastructure ships here.
Add an organization-context panel
Keep a buyer extension small: a shared output schema, a domain read, a guarded procedure, and a localized component. For example, a Chat inventory summary can show a count without exposing chat bodies:
// packages/shared/src/schemas/support.ts
export const supportChatInventorySchema = z.object({ count: z.number().int() });
// packages/core/src/support/chat-inventory.ts
export async function getChatInventory(organizationId: string) {
await requireSupportOrganization(organizationId);
return { count: await prisma.chat.count({ where: { organizationId } }) };
}
// packages/api/src/router/support.ts
export const chatInventory = supportProcedure
.input(supportOrganizationSchema)
.output(supportChatInventorySchema)
.handler(({ input }) => getChatInventory(input.organizationId));Export the schema/domain function from their barrels, register the procedure in
router.support, and consume orpc.support.chatInventory.queryOptions from the
selected organization's panel. Keep its query independent so a failed extension
does not hide billing or membership. Test tenant-admin denial and output privacy.
For a named action, validate current authorization and the target, then call
beginSupportAction with a stable request UUID, reason, and non-secret parameters.
Only execute the owning use-case if execute is true. Persist the actual result
with finishSupportAction; preserve uncertainty if work may have completed before
an exception. Provide a verification seam specific to the entity instead of
adding arbitrary database-edit or generic retry APIs. The repository skill
extend-support-console describes these seams for coding agents.
Background-job scheduling/inspection, manual webhook replay for Polar and Creem, custom operator role management, full ticketing, and new AI cost telemetry are deferred.