utils/billingErrors.ts
Rama main · réplica de lectura del repositorio original.
/**
* Billing-error classification + user-facing copy for `/api/proxy-token`
* failures and OpenRouter mid-run exhaustion. Two error classes (Billing vs.
* Transient) keep the framing honest: a card decline is *not* the same UX as
* a 503 from the proxy service. Both originate in `utils/proxy.ts` (mint
* failures) and `utils/runErrorRenderer.ts` (mid-run keylimit reclassify).
*
* Renderers return markdown bodies that are written into both the GitHub
* Actions job summary and the PR progress comment.
*
* Lives outside `main.ts` so adding a new error `code` branch is a one-file
* edit rather than an orchestrator edit (see wiki/main.md).
*/
import { renderFailure } from "./failureBody.ts";
/**
* Billing-layer error surfaced from `/api/proxy-token` as a 402. User-actionable,
* distinct from TransientError (503 / transient sync issue) so the job
* summary + PR comment can use affirmative "you need to do X" copy rather than
* the ambiguous "billing error" label that makes transient outages look like
* the user's fault.
*
* `code` is a server-side discriminator: `router_requires_card` (no card + no
* wallet balance on Router), or null for unclassified. `declineCode` is
* Stripe's more specific sub-reason on `card_declined` (e.g.
* `insufficient_funds`, `lost_card`). `needsReauthentication` is the 3DS case
* broken out for convenience.
*/
export class BillingError extends Error {
code: string | null;
declineCode: string | null;
needsReauthentication: boolean;
constructor(
message: string,
opts: {
code?: string | null;
declineCode?: string | null;
needsReauthentication?: boolean;
} = {}
) {
super(message);
this.name = "BillingError";
this.code = opts.code ?? null;
this.declineCode = opts.declineCode ?? null;
this.needsReauthentication = opts.needsReauthentication ?? false;
}
}
/**
* Transient service failures from `/api/proxy-token` (503: partial OpenRouter
* usage sync, DB flake, in-flight payment intent). Not the user's fault, the
* summary uses "temporarily unavailable" framing, and the non-zero exit lets
* GH Actions apply whatever retry policy the workflow has configured.
*/
export class TransientError extends Error {
/** the customer-facing follow-up to `message`, which is the headline. */
detail: string;
constructor(
message: string,
detail = "This is on our side, and nothing was billed. The next run should succeed; if it keeps failing, check [status.pullfrog.com](https://status.pullfrog.com)."
) {
super(message);
this.name = "TransientError";
this.detail = detail;
}
}
/**
* Deep link into the billing section of the failing account's console. Since
* the billing + model-costs cards were merged into one `#billing` section,
* every billing CTA points here. `owner` is the GitHub login of the repo's
* account, the org or user that pays for this repo's runs.
*/
function billingConsoleUrl(owner: string): string {
return `https://pullfrog.com/console/${encodeURIComponent(owner)}#billing`;
}
/**
* Why the org commercial gate refused a run (billing model v2), resolved by
* `resolveTeamRunAccess` and carried unchanged through the paywall comment, the
* run-context 402 and the proxy-token 402. `commercial`: the no-card trial
* wound down to `paused`. `subscription_ended`: a paid subscription was
* canceled and lapsed to `paused`. `subscription_unpaid`: the renewal failed
* dunning.
*/
export type CommercialRefusal = "commercial" | "subscription_ended" | "subscription_unpaid";
/**
* Canonical paused-run paywall body (billing model v2). Single source of truth so
* the two surfaces that show it can never drift: the trigger-time comment (a run
* refused before it starts, `triggerWorkflow.postPaywallComment`) and the mid-run
* 402 render (`formatBillingErrorSummary` via `renderRunError`). `url` is the
* caller's billing surface (each passes its own, internal plan page vs console).
*/
export function commercialPaywallBody(params: {
reason: CommercialRefusal;
ownerLogin: string;
url: string;
freshReply?: boolean | undefined;
}): string {
let action: string;
switch (params.reason) {
case "commercial":
action = "upgrade";
break;
case "subscription_ended":
action = "resubscribe";
break;
case "subscription_unpaid":
action = "update billing";
break;
default:
return params.reason satisfies never;
}
return [
`**Pullfrog is ${params.freshReply ? "still paused" : "paused"} for ${params.ownerLogin}.**`,
"",
`An org owner can [${action}](${params.url}) to resume private-repo runs.`,
].join("\n");
}
/**
* commercial-gate copy for action runs, linked to the account billing card.
*
* if this fires on a `PullfrogDev/preview-*` repo it is an infrastructure bug, not
* a billing state — those repos have no real billing state to refuse. the
* original cause was the gate comparing the subscription's originating host
* with strict equality, which no preview could satisfy against a cloned
* production row; see `isSubscriptionHostTrusted` and wiki/billing-model-v2.md.
*/
export function formatCommercialGateSummary(params: {
reason: CommercialRefusal;
ownerLogin: string;
}): string {
return commercialPaywallBody({
reason: params.reason,
ownerLogin: params.ownerLogin,
url: billingConsoleUrl(params.ownerLogin),
});
}
/**
* Render a BillingError as user-facing markdown (shared between GH job summary
* and the PR progress comment). Goals:
*
* - quiet, not alarmist, bold first line instead of an `### ❌` H3, since
* the comment already has Pullfrog branding in the footer
* - actionable, every branch ends in a single CTA deep-linked to the
* correct section of the owner's console
* - honest, say what actually went wrong (card declined vs. balance
* empty vs. 3DS required), don't lump them under "billing error"
*
* Branches:
* - `router_requires_card`: user is on Router mode with no card AND no
* wallet balance. Frame as
* "add a card to continue", link to `#billing` where the Add
* Card flow lives.
* - `router_balance_exhausted`: user has a card on file but auto-reload is
* disabled and they've spent past their $5 overdraft buffer. Frame as
* "balance ran out" and surface both remediation paths (top up, or flip
* on auto-reload).
* - `router_keylimit_exhausted`: OpenRouter rejected mid-run because the
* per-run key budget was exhausted while the agent was working. The
* wallet is now negative; same remediation as `router_balance_exhausted`
* but framed for the after-the-fact case ("this run was cut short").
* - `needsReauthentication`: issuer requires 3DS on every off-session
* charge. Re-adding the card won't help, the only escape is a manual
* top-up where 3DS runs interactively in Stripe Checkout.
* - `declineCode` set: Stripe declined a real charge. Show the sub-code
* so support can act on it; tell the user we'll retry on next dispatch.
* - default: balance hit zero with no in-flight charge (auto-reload off
* or amount below threshold). Direct them to top up or enable auto-reload.
*/
export function formatBillingErrorSummary(error: BillingError, owner: string): string {
const billing = { label: "Add credit", url: billingConsoleUrl(owner) };
if (error.code === "router_requires_card") {
return renderFailure({
headline: "Your Pullfrog Router balance is empty.",
detail:
"Add a card to top it up, or connect your own provider key. Router usage is billed at provider cost with no markup.",
links: [{ label: "Billing and providers", url: billingConsoleUrl(owner) }],
});
}
if (error.code === "router_balance_exhausted") {
return renderFailure({
headline: "Your Pullfrog Router balance is used up.",
detail:
"Auto-reload is off, so runs paused once the balance passed its overdraft buffer. Add credit or turn on auto-reload.",
links: [billing],
});
}
if (error.code === "router_keylimit_exhausted") {
return renderFailure({
headline: "Your Pullfrog Router balance ran out partway through this run.",
detail: "Add credit or turn on auto-reload to keep runs going.",
links: [billing],
});
}
if (error.code === "router_monthly_limit") {
return renderFailure({
headline:
"Your Pullfrog Router balance is empty, and the monthly spend limit blocks the next auto-reload.",
detail:
"Add credit by hand, ask an org owner to raise the limit, or wait for it to reset at 00:00 UTC on the 1st.",
links: [billing],
});
}
// billing model v2 org commercial gate: the org's Team trial wound down to a
// pause (or a paid renewal failed). Individual members keep Pullfrog free on
// their own repos; only the org's runs are paused.
// `#billing` (billingConsoleUrl) is the card-management surface, matching
// router_requires_card; keep every commercial-gate CTA pointed there.
if (error.code === "commercial_plan_required") {
return formatCommercialGateSummary({
reason: "commercial",
ownerLogin: owner,
});
}
if (error.code === "subscription_ended") {
return formatCommercialGateSummary({
reason: "subscription_ended",
ownerLogin: owner,
});
}
if (error.code === "subscription_unpaid") {
return formatCommercialGateSummary({
reason: "subscription_unpaid",
ownerLogin: owner,
});
}
if (error.needsReauthentication) {
const code = error.declineCode ?? "authentication_required";
return renderFailure({
headline: `Your card issuer requires 3D Secure on every charge (\`${code}\`).`,
detail:
"Pullfrog can't complete a 3D Secure check from inside a workflow. Add credit once by hand, and later runs draw on that balance without another check.",
links: [billing],
});
}
if (error.declineCode) {
return renderFailure({
headline: `Your card was declined (\`${error.declineCode}\`).`,
detail: "Update your payment method, and Pullfrog will retry on the next run.",
links: [{ label: "Update payment method", url: billingConsoleUrl(owner) }],
});
}
return renderFailure({
headline: "Your Pullfrog balance is empty.",
detail: "Add credit or turn on auto-reload to keep runs going.",
links: [billing],
});
}
/**
* Render a TransientError as user-facing markdown. Distinct framing from
* BillingError so the user doesn't read an alarm and assume their card
* failed, this branch is "our fault, retry shortly", not theirs. Both parts are
* ours and customer-ready; the server's own wording goes to the log.
*/
export function formatTransientErrorSummary(error: TransientError): string {
return renderFailure({ headline: error.message, detail: error.detail });
}