164 lines
5.1 KiB
TypeScript
164 lines
5.1 KiB
TypeScript
import type { BillingRefusal } from './api'
|
|
|
|
export interface BillingRefusalPresentation {
|
|
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
|
|
message: string
|
|
title: string
|
|
}
|
|
|
|
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
|
|
|
|
const retryMessage = (refusal: BillingRefusal): string => {
|
|
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
|
|
|
|
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
|
|
}
|
|
|
|
const stripeRetryMessage = (refusal: BillingRefusal): string => {
|
|
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
|
|
|
|
return `Stripe is having trouble — try again shortly${mins}`
|
|
}
|
|
|
|
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
|
|
switch (refusal.kind) {
|
|
case 'consent_required':
|
|
return {
|
|
action: portalAction(refusal.portalUrl),
|
|
message: 'Confirm this card for terminal charges in the portal',
|
|
title: 'Card confirmation needed'
|
|
}
|
|
|
|
case 'insufficient_scope':
|
|
return {
|
|
action: { type: 'step_up' },
|
|
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
|
|
title: 'Remote Spending needs approval'
|
|
}
|
|
case 'remote_spending_revoked': {
|
|
const who =
|
|
refusal.actor === 'admin'
|
|
? 'An admin stopped remote spending for this terminal.'
|
|
: 'You stopped remote spending for this terminal.'
|
|
|
|
return {
|
|
action: portalAction(refusal.portalUrl),
|
|
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
|
|
title: 'Remote spending was stopped'
|
|
}
|
|
}
|
|
|
|
case 'session_revoked':
|
|
return {
|
|
action: portalAction(refusal.portalUrl),
|
|
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
|
|
title: 'Session logged out'
|
|
}
|
|
|
|
case 'cli_billing_disabled':
|
|
|
|
case 'remote_spending_disabled':
|
|
return {
|
|
action: portalAction(refusal.portalUrl),
|
|
message:
|
|
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
|
|
title: 'Remote spending is off'
|
|
}
|
|
|
|
case 'role_required':
|
|
return {
|
|
action: portalAction(refusal.portalUrl),
|
|
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
|
|
title: 'Admin role required'
|
|
}
|
|
|
|
case 'idempotency_conflict':
|
|
return {
|
|
action: { type: 'none' },
|
|
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
|
|
title: 'Start a fresh top-up'
|
|
}
|
|
|
|
case 'no_payment_method':
|
|
return {
|
|
action: portalAction(refusal.portalUrl),
|
|
message:
|
|
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
|
|
"(one-time credit buys don't save a reusable card).",
|
|
title: 'No saved card'
|
|
}
|
|
|
|
case 'org_access_denied':
|
|
return {
|
|
action: { type: 'none' },
|
|
message: "This token isn't bound to an org you can manage",
|
|
title: 'Org access denied'
|
|
}
|
|
case 'monthly_cap_exceeded': {
|
|
const remaining = refusal.payload?.remainingUsd
|
|
|
|
return {
|
|
action: portalAction(refusal.portalUrl),
|
|
message:
|
|
remaining != null
|
|
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
|
|
: '🔴 Monthly spend cap reached.',
|
|
title: 'Monthly spend cap reached'
|
|
}
|
|
}
|
|
|
|
case 'rate_limited':
|
|
|
|
case 'temporarily_unavailable':
|
|
return {
|
|
action: { type: 'retry' },
|
|
message: retryMessage(refusal),
|
|
title: 'Too many charges right now'
|
|
}
|
|
|
|
case 'stripe_unavailable':
|
|
return {
|
|
action: { type: 'retry' },
|
|
message: stripeRetryMessage(refusal),
|
|
title: 'Stripe is having trouble'
|
|
}
|
|
|
|
case 'upgrade_cap_exceeded':
|
|
return {
|
|
action: { type: 'none' },
|
|
message: 'Daily plan-change limit reached — try again tomorrow',
|
|
title: 'Daily plan-change limit reached'
|
|
}
|
|
|
|
case 'endpoint_unavailable':
|
|
return {
|
|
action: { type: 'retry' },
|
|
message:
|
|
refusal.message ||
|
|
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
|
|
title: 'Billing endpoint unavailable'
|
|
}
|
|
|
|
case 'timeout':
|
|
return {
|
|
action: { type: 'retry' },
|
|
message: refusal.message || 'Billing request timed out.',
|
|
title: 'Billing request timed out'
|
|
}
|
|
|
|
case 'transport':
|
|
return {
|
|
action: { type: 'retry' },
|
|
message: refusal.message || 'Billing request failed before reaching the gateway.',
|
|
title: 'Billing connection failed'
|
|
}
|
|
|
|
default:
|
|
return {
|
|
action: { type: 'none' },
|
|
message: refusal.message || 'Billing request failed.',
|
|
title: 'Billing request failed'
|
|
}
|
|
}
|
|
}
|