action: quieter, deep-linked billing error comments (#600)
* action: quieter, deep-linked billing error comments The PR progress comment for billing errors led with a loud `### ❌ Pullfrog billing error` H3 and pointed at the bare `/console` index page regardless of which org owned the repo. Make the copy quieter and more actionable: - bold first line instead of an H3 (the comment already has Pullfrog branding in the footer, no need for a second header) - thread `runContext.repo.owner` into the formatters and deep-link to `pullfrog.com/console/<owner>#billing` (or `#model-access` for the router-needs-card branch) - split the old "insufficient balance" default into two branches: card declined (Stripe returned a declineCode — "we'll retry next run") vs. balance empty (no in-flight charge — "top up or enable auto-reload") - strip UX framing and pullfrog.com URLs from the proxy-token 402 responses; they're now terse signal-only strings, with all copy and links rendered by the action so there's a single source of truth * proxy-token: return 503 on phase-1 txn failure, not 402 Phase-1 only fails on server-side issues (serializable retry exhaustion, Prisma/DB flake) — no Stripe call has happened yet, so it's not a billing decline. Pre-PR this rendered as the generic "billing error — manage billing" copy, which was vague-but-not-wrong; under the new copy it would falsely tell the user their balance is empty. Returning 503 routes the action through TransientError ("temporarily unavailable, retry") which is the accurate framing. Caught by Pullfrog review on PR #600.
This commit is contained in:
committed by
pullfrog[bot]
parent
cd9e00f8d6
commit
ae8a634450
@@ -160,55 +160,91 @@ class TransientError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a BillingError as user-facing markdown (shared between GH job summary
|
||||
* and the PR progress comment). Branches:
|
||||
*
|
||||
* - `router_requires_card`: the user is on Router mode with no card AND
|
||||
* no wallet balance. Points at the add-card flow in the console.
|
||||
* - `needsReauthentication` (Stripe `authentication_required` decline): the
|
||||
* issuer requires a 3D Secure challenge on each off-session charge —
|
||||
* re-adding the card won't help because the issuer's policy persists
|
||||
* across credentials. The escape valve is a manual top-up from the
|
||||
* dashboard, where 3DS runs interactively inside Stripe Checkout.
|
||||
* - default: generic "manage billing" with the declineCode appended if
|
||||
* classified (insufficient_funds, lost_card, generic_decline, etc).
|
||||
* Deep link into the right console section for the failing account. Anchors
|
||||
* are defined in `app/console/[owner]/page.tsx` (`#billing`, `#model-access`).
|
||||
* `owner` is the GitHub login of the repo's account — i.e. the org or user
|
||||
* that pays for this repo's runs, which is the right scope for billing.
|
||||
*/
|
||||
function formatBillingErrorSummary(error: BillingError): string {
|
||||
function billingConsoleUrl(owner: string, anchor: "billing" | "model-access"): string {
|
||||
return `https://pullfrog.com/console/${encodeURIComponent(owner)}#${anchor}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Lead with the carrot ($20 free credit), link to
|
||||
* `#model-access` where the Add Card flow lives.
|
||||
* - `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.
|
||||
*/
|
||||
function formatBillingErrorSummary(error: BillingError, owner: string): string {
|
||||
if (error.code === "router_requires_card") {
|
||||
return [
|
||||
"### ⛔ Pullfrog Router requires a card",
|
||||
"**Add a card to start using Pullfrog Router.**",
|
||||
"",
|
||||
"This run was going to use Pullfrog Router, which bills at raw OpenRouter cost and needs a card on file. Runs won't proceed until a card is added.",
|
||||
"Router proxies OpenRouter at raw cost — no platform markup, and your first $20 of usage is on us.",
|
||||
"",
|
||||
"[Add a card →](https://pullfrog.com/console#model-access) — your first $20 of Router usage is free.",
|
||||
`[Add a card →](${billingConsoleUrl(owner, "model-access")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (error.needsReauthentication) {
|
||||
const code = error.declineCode ?? "authentication_required";
|
||||
return [
|
||||
"### ❌ Pullfrog billing error — card requires 3DS on every charge",
|
||||
`**Your card issuer requires 3D Secure on every charge** (\`${code}\`).`,
|
||||
"",
|
||||
`Your card issuer requires a 3D Secure challenge on each off-session charge (\`${error.declineCode ?? "authentication_required"}\`), which we can't run from the agent. Top up your Router credit balance manually — 3DS runs interactively in Stripe Checkout, and subsequent runs draw from the prepaid balance without triggering another off-session charge.`,
|
||||
"Pullfrog can't complete a 3DS challenge from inside a workflow. Top up your Router balance once in Stripe Checkout — subsequent runs draw from the prepaid balance without re-triggering 3DS.",
|
||||
"",
|
||||
"[Top up your Router credit balance →](https://pullfrog.com/console)",
|
||||
`[Top up balance →](${billingConsoleUrl(owner, "billing")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const codeSuffix = error.declineCode ? ` (\`${error.declineCode}\`)` : "";
|
||||
return `### ❌ Pullfrog billing error\n\n${error.message}${codeSuffix}\n\n[Manage billing →](https://pullfrog.com/console)`;
|
||||
if (error.declineCode) {
|
||||
return [
|
||||
`**Your card was declined** (\`${error.declineCode}\`).`,
|
||||
"",
|
||||
"Update your payment method and Pullfrog will retry on the next run.",
|
||||
"",
|
||||
`[Update payment method →](${billingConsoleUrl(owner, "billing")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"**Your Pullfrog balance is empty.**",
|
||||
"",
|
||||
"Top up your balance or enable auto-reload to keep runs flowing.",
|
||||
"",
|
||||
`[Manage billing →](${billingConsoleUrl(owner, "billing")})`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a TransientError as user-facing markdown. Distinct framing from
|
||||
* BillingError so the user doesn't read "❌" and assume their card failed.
|
||||
* BillingError so the user doesn't read an alarm and assume their card
|
||||
* failed — this branch is "our fault, retry shortly", not theirs.
|
||||
*/
|
||||
function formatTransientErrorSummary(error: TransientError): string {
|
||||
function formatTransientErrorSummary(error: TransientError, owner: string): string {
|
||||
return [
|
||||
"### ⚠️ Pullfrog temporarily unavailable",
|
||||
"**Pullfrog billing is temporarily unavailable.**",
|
||||
"",
|
||||
error.message,
|
||||
"",
|
||||
"This is typically transient — the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com).",
|
||||
`Usually transient — the next dispatch should succeed. If it persists, check [status.pullfrog.com](https://status.pullfrog.com) or [your console](${billingConsoleUrl(owner, "billing")}).`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -395,7 +431,7 @@ export async function main(): Promise<MainResult> {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) {
|
||||
const summary = formatBillingErrorSummary(error);
|
||||
const summary = formatBillingErrorSummary(error, runContext.repo.owner);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
// Mirror to the PR progress comment if the trigger created one
|
||||
// (mention / PR event). Without this, auto-reload declines are only
|
||||
@@ -405,7 +441,7 @@ export async function main(): Promise<MainResult> {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof TransientError) {
|
||||
const summary = formatTransientErrorSummary(error);
|
||||
const summary = formatTransientErrorSummary(error, runContext.repo.owner);
|
||||
await writeSummary(summary).catch(() => {});
|
||||
await reportErrorToComment({ toolState, error: summary }).catch(() => {});
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user