diff --git a/app/api/pymthouse/invoices/[invoiceId]/hosted-url/route.ts b/app/api/pymthouse/invoices/[invoiceId]/hosted-url/route.ts new file mode 100644 index 0000000..942c391 --- /dev/null +++ b/app/api/pymthouse/invoices/[invoiceId]/hosted-url/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDashboardUserInvoiceHostedUrl } from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ invoiceId: string }> } +) { + const { invoiceId: rawInvoiceId } = await params; + const invoiceId = decodeURIComponent(rawInvoiceId).trim(); + + if (!invoiceId) { + return NextResponse.json( + { error: "invoiceId is required" }, + { status: 400 } + ); + } + + try { + const session = await requireConsoleSession(); + const links = await getDashboardUserInvoiceHostedUrl( + session.externalUserId, + invoiceId + ); + return NextResponse.json(links, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to resolve invoice link"); + } +} diff --git a/app/api/pymthouse/invoices/route.ts b/app/api/pymthouse/invoices/route.ts new file mode 100644 index 0000000..8b0c18d --- /dev/null +++ b/app/api/pymthouse/invoices/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { listDashboardUserInvoices } from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const pageRaw = Number(request.nextUrl.searchParams.get("page") || "1"); + const pageSizeRaw = Number( + request.nextUrl.searchParams.get("pageSize") || "20" + ); + + try { + const session = await requireConsoleSession(); + const result = await listDashboardUserInvoices(session.externalUserId, { + page: Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1, + pageSize: + Number.isFinite(pageSizeRaw) && pageSizeRaw > 0 ? pageSizeRaw : 20, + }); + return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to load invoices"); + } +} diff --git a/app/api/pymthouse/payment-methods/route.ts b/app/api/pymthouse/payment-methods/route.ts new file mode 100644 index 0000000..7d44725 --- /dev/null +++ b/app/api/pymthouse/payment-methods/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + ensureDashboardUserDefaultPaymentMethod, + listDashboardUserPaymentMethods, + removeDashboardUserPaymentMethod, + setDashboardUserDefaultPaymentMethod, + startDashboardPaymentMethodCheckout, +} from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + checkoutReturnOrigin, + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const session = await requireConsoleSession(); + const paymentMethods = await listDashboardUserPaymentMethods( + session.externalUserId + ); + return NextResponse.json( + { paymentMethods }, + { headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to load payment methods"); + } +} + +export async function POST(request: NextRequest) { + let body: { + successUrl?: string; + cancelUrl?: string; + }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const origin = checkoutReturnOrigin(request); + const successUrl = + body.successUrl?.trim() || + `${origin}/settings?tab=billing&checkout=success`; + const cancelUrl = + body.cancelUrl?.trim() || `${origin}/settings?tab=billing&checkout=cancel`; + + try { + const session = await requireConsoleSession(); + const result = await startDashboardPaymentMethodCheckout({ + externalUserId: session.externalUserId, + successUrl, + cancelUrl, + }); + return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse( + error, + "Failed to start payment method checkout" + ); + } +} + +export async function PATCH(request: NextRequest) { + let body: { + paymentMethodId?: string; + ensureDefault?: boolean; + }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + try { + const session = await requireConsoleSession(); + if (body.ensureDefault === true) { + return NextResponse.json( + await ensureDashboardUserDefaultPaymentMethod(session.externalUserId), + { headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } + + const paymentMethodId = body.paymentMethodId?.trim(); + if (!paymentMethodId) { + return NextResponse.json( + { error: "paymentMethodId is required" }, + { status: 400 } + ); + } + + return NextResponse.json( + await setDashboardUserDefaultPaymentMethod( + session.externalUserId, + paymentMethodId + ), + { headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to set default payment method"); + } +} + +export async function DELETE(request: NextRequest) { + let body: { paymentMethodId?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const paymentMethodId = body.paymentMethodId?.trim(); + if (!paymentMethodId) { + return NextResponse.json( + { error: "paymentMethodId is required" }, + { status: 400 } + ); + } + + try { + const session = await requireConsoleSession(); + return NextResponse.json( + await removeDashboardUserPaymentMethod( + session.externalUserId, + paymentMethodId + ), + { headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to remove payment method"); + } +} diff --git a/app/api/pymthouse/wallet/invoices/route.ts b/app/api/pymthouse/wallet/invoices/route.ts new file mode 100644 index 0000000..9941b37 --- /dev/null +++ b/app/api/pymthouse/wallet/invoices/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { listDashboardWalletInvoices } from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function parsePageParam(raw: string | null): number | undefined { + if (!raw || !/^[1-9]\d*$/.test(raw)) return undefined; + return Number(raw); +} + +export async function GET(request: NextRequest) { + const page = parsePageParam(request.nextUrl.searchParams.get("page")); + const pageSize = parsePageParam(request.nextUrl.searchParams.get("pageSize")); + + try { + const session = await requireConsoleSession(); + const result = await listDashboardWalletInvoices({ + externalUserId: session.externalUserId, + page, + pageSize, + }); + return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to load wallet invoices"); + } +} diff --git a/app/api/pymthouse/wallet/payment-methods/route.ts b/app/api/pymthouse/wallet/payment-methods/route.ts new file mode 100644 index 0000000..dd11820 --- /dev/null +++ b/app/api/pymthouse/wallet/payment-methods/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + ensureDashboardWalletDefaultPaymentMethod, + listDashboardWalletPaymentMethods, + startDashboardWalletPaymentMethodCheckout, +} from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + checkoutReturnOrigin, + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const session = await requireConsoleSession(); + const paymentMethods = await listDashboardWalletPaymentMethods( + session.externalUserId + ); + return NextResponse.json( + { paymentMethods }, + { headers: PYMTHOUSE_NO_STORE_HEADERS } + ); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to load wallet payment methods"); + } +} + +export async function POST(request: NextRequest) { + let body: { + successUrl?: string; + cancelUrl?: string; + }; + try { + body = (await request.json()) as typeof body; + } catch { + body = {}; + } + + const origin = checkoutReturnOrigin(request); + const successUrl = + body.successUrl?.trim() || `${origin}/usage?topup=pm-saved`; + const cancelUrl = body.cancelUrl?.trim() || `${origin}/usage?topup=canceled`; + + try { + const session = await requireConsoleSession(); + const result = await startDashboardWalletPaymentMethodCheckout({ + externalUserId: session.externalUserId, + successUrl, + cancelUrl, + }); + return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse( + error, + "Failed to start payment method checkout" + ); + } +} + +export async function PATCH(request: NextRequest) { + let body: { ensureDefault?: boolean }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + if (body.ensureDefault !== true) { + return NextResponse.json( + { error: "ensureDefault: true is required" }, + { status: 400 } + ); + } + + try { + const session = await requireConsoleSession(); + const result = await ensureDashboardWalletDefaultPaymentMethod( + session.externalUserId + ); + return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse( + error, + "Failed to ensure default payment method" + ); + } +} diff --git a/app/api/pymthouse/wallet/route.ts b/app/api/pymthouse/wallet/route.ts new file mode 100644 index 0000000..acd1e76 --- /dev/null +++ b/app/api/pymthouse/wallet/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getDashboardOwnerWallet } from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const session = await requireConsoleSession(); + const wallet = await getDashboardOwnerWallet(session.externalUserId); + return NextResponse.json(wallet, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to load wallet"); + } +} diff --git a/app/api/pymthouse/wallet/top-up/route.ts b/app/api/pymthouse/wallet/top-up/route.ts new file mode 100644 index 0000000..76a1d4f --- /dev/null +++ b/app/api/pymthouse/wallet/top-up/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { startDashboardWalletTopUp } from "@/lib/console/pymthouse-billing-bff"; +import { requireConsoleSession } from "@/lib/console/session-user"; +import { + checkoutReturnOrigin, + PYMTHOUSE_NO_STORE_HEADERS, + pymthouseErrorResponse, +} from "@/app/api/pymthouse/route-helpers"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + let body: { + amountUsd?: string | number; + successUrl?: string; + cancelUrl?: string; + }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const amountUsd = + typeof body.amountUsd === "number" + ? body.amountUsd.toFixed(2) + : body.amountUsd?.trim(); + if (!amountUsd) { + return NextResponse.json( + { error: 'amountUsd is required (e.g. "25.00")' }, + { status: 400 } + ); + } + + const origin = checkoutReturnOrigin(request); + const successUrl = + body.successUrl?.trim() || `${origin}/usage?topup=succeeded`; + const cancelUrl = body.cancelUrl?.trim() || `${origin}/usage?topup=canceled`; + + try { + const session = await requireConsoleSession(); + const result = await startDashboardWalletTopUp({ + amountUsd, + externalUserId: session.externalUserId, + successUrl, + cancelUrl, + }); + return NextResponse.json(result, { headers: PYMTHOUSE_NO_STORE_HEADERS }); + } catch (error) { + return pymthouseErrorResponse(error, "Failed to start top-up checkout"); + } +} diff --git a/components/console/PlansPanel.tsx b/components/console/PlansPanel.tsx index 922427a..7d39539 100644 --- a/components/console/PlansPanel.tsx +++ b/components/console/PlansPanel.tsx @@ -21,6 +21,11 @@ import { } from "@/lib/console/useBillingPlans"; import { redirectToCheckout } from "@/lib/console/checkout-redirect"; import { useAuth } from "@/components/console/AuthContext"; +import { useWalletBillingState } from "@/lib/console/useOwnerWallet"; +import { + includedUsageRemainingLabel, + includedUsageSummary, +} from "@/lib/console/wallet-settlement-display"; function isUsagePlan( plan: Pick @@ -77,6 +82,11 @@ function clearCheckoutQueryParam(): void { export default function PlansPanel() { const { isConnected } = useAuth(); const { state, reload, subscribe, changePlan } = useBillingPlans(isConnected); + const wallet = useWalletBillingState(isConnected); + const included = + wallet.state.status === "ready" + ? includedUsageSummary(wallet.state.wallet.billingState) + : null; const [busyPlanId, setBusyPlanId] = useState(null); const [error, setError] = useState(null); const [flash, setFlash] = useState<"success" | "cancel" | null>(null); @@ -310,7 +320,9 @@ export default function PlansPanel() {

Plans

- Subscribe via PymtHouse → Stripe Checkout + {included + ? includedUsageRemainingLabel(included) + : "Subscribe via PymtHouse → Stripe Checkout"}

{flash === "success" ? (

@@ -343,6 +355,12 @@ export default function PlansPanel() { ? ` · ${plan.capabilityCount} capabilities` : ""}

+ {isCurrent && included && included.planId === plan.id ? ( +

+ ${included.remainingUsd} of ${included.totalUsd} included + left +

+ ) : null} {isUsagePlan(plan) ? (

{resolvedPayPerUseBehavior(plan)} diff --git a/components/console/SidebarUsageCard.tsx b/components/console/SidebarUsageCard.tsx index 7fb4219..2983af5 100644 --- a/components/console/SidebarUsageCard.tsx +++ b/components/console/SidebarUsageCard.tsx @@ -4,17 +4,32 @@ import type { ReactNode } from "react"; import Link from "next/link"; import { useAuth } from "@/components/console/AuthContext"; import { useAccountUsage } from "@/lib/console/useAccountUsage"; -import { formatPeriodResetLabel } from "@/lib/console/usage-capability-display"; +import { useWalletBillingState } from "@/lib/console/useOwnerWallet"; +import { + formatPeriodResetLabel, + microsToUsd, +} from "@/lib/console/usage-capability-display"; +import { includedUsageSummary } from "@/lib/console/wallet-settlement-display"; /** - * Sidebar usage meter. Included-allowance copy lands with the wallet PR; - * until then this shows period spend from OpenMeter. + * Sidebar usage meter. Remaining included usage comes from the wallet + * billing state when the live plan has an allowance; otherwise period spend. */ export default function SidebarUsageCard() { const { isConnected } = useAuth(); const usage = useAccountUsage(isConnected, 30); + const wallet = useWalletBillingState(isConnected); + const included = + wallet.state.status === "ready" + ? includedUsageSummary(wallet.state.wallet.billingState) + : null; - if (usage.status === "loading" || usage.status === "idle") { + if ( + usage.status === "loading" || + usage.status === "idle" || + (isConnected && + (wallet.state.status === "loading" || wallet.state.status === "idle")) + ) { return (

${spendUsd.toFixed(2)} - ); + const showUsdAllowance = Boolean(included); + + const resetsAt = included?.resetsAt + ? new Date(included.resetsAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) + : formatPeriodResetLabel(data.period.end); + const planLabel = + included?.planName?.trim() || (showUsdAllowance ? "Included usage" : "Usage"); + + let primaryUsed: number; + let primaryLimit: number | null; + let primaryDisplay: ReactNode; + let footerLeft: string; + + if (showUsdAllowance && included) { + const granted = BigInt(included.totalUsdMicros || "1"); + const consumed = BigInt(included.consumedUsdMicros || "0"); + primaryUsed = Number((consumed * BigInt(10000)) / granted) / 100; + primaryLimit = 100; + primaryDisplay = ( + <> + + ${microsToUsd(included.consumedUsdMicros).toFixed(2)} + + + {" "} + / ${microsToUsd(included.totalUsdMicros).toFixed(2)} + + + ); + footerLeft = "used"; + } else { + const spendUsd = + Number( + BigInt( + data.current.endUserBillableUsdMicros || + data.current.networkFeeUsdMicros || + "0" + ) + ) / 1_000_000; + primaryUsed = 0; + primaryLimit = null; + primaryDisplay = ( + ${spendUsd.toFixed(2)} + ); + footerLeft = "spent"; + } + + const pct = + primaryLimit && primaryLimit > 0 + ? Math.min(100, (primaryUsed / primaryLimit) * 100) + : 0; return (
- Usage + {planLabel} {primaryDisplay} @@ -74,21 +132,21 @@ export default function SidebarUsageCard() { >
- spent + {footerLeft} - resets {formatPeriodResetLabel(data.period.end)} + resets {resetsAt}
diff --git a/components/console/UsageView.tsx b/components/console/UsageView.tsx index e31a466..d74cfb1 100644 --- a/components/console/UsageView.tsx +++ b/components/console/UsageView.tsx @@ -16,15 +16,12 @@ import { } from "@/lib/console/usage-capability-display"; import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton"; import PlansPanel from "@/components/console/PlansPanel"; - -type IncludedUsageSummary = { - planName?: string; - consumedUsdMicros: string; - totalUsdMicros: string; - remainingUsdMicros: string; - totalUsd: string; - resetsAt?: string; -}; +import WalletPanel from "@/components/console/WalletPanel"; +import { useWalletBillingState } from "@/lib/console/useOwnerWallet"; +import { + includedUsageSummary, + type IncludedUsageSummary, +} from "@/lib/console/wallet-settlement-display"; const PERIOD_DAYS = 30; @@ -196,6 +193,7 @@ function AllowanceStrip({ export default function UsageView() { const { isConnected, user } = useAuth(); const usageState = useAccountUsage(isConnected, PERIOD_DAYS); + const walletState = useWalletBillingState(isConnected); const [priceMin, setPriceMin] = useState(0); const [priceMax, setPriceMax] = useState(100); @@ -301,8 +299,16 @@ export default function UsageView() { const { data } = usageState; const grandReq = filteredRows.reduce((a, c) => a + c.requestCount, 0); const grandSpend = filteredRows.reduce((a, c) => a + c.spendUsd, 0); - const included: IncludedUsageSummary | null = null; - const resetsAt = formatPeriodResetLabel(data.period.end); + const included: IncludedUsageSummary | null = + walletState.state.status === "ready" + ? includedUsageSummary(walletState.state.wallet.billingState) + : null; + const resetsAt = included?.resetsAt + ? new Date(included.resetsAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) + : formatPeriodResetLabel(data.period.end); return (
@@ -312,6 +318,14 @@ export default function UsageView() { + + = { + ok: "border-emerald-400/30 text-emerald-400", + info: "border-hairline text-fg-muted", + warn: "border-amber-400/30 text-amber-400", + danger: "border-rose-400/30 text-rose-400", +}; + +const AVAILABLE_TONE_CLASS: Record = { + ok: "text-fg", + info: "text-fg", + warn: "text-amber-400", + danger: "text-rose-400", +}; + +export default function WalletPanel({ + periodBillableUsdMicros = null, +}: { + /** Period end-user billable USD micros from the Usage page (metered usage, not credits). */ + periodBillableUsdMicros?: string | null; +}) { + const { isConnected } = useAuth(); + const { + state, + reload, + startTopUp, + startPaymentMethodCheckout, + ensureDefaultPaymentMethod, + } = useOwnerWallet(isConnected); + const [showTopUp, setShowTopUp] = useState(false); + const [amountUsd, setAmountUsd] = useState("25.00"); + const [busy, setBusy] = useState<"topup" | "pm" | null>(null); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + + useEffect(() => { + const next = readTopUpFlash(); + if (!next) return; + setFlash(next); + clearTopUpQueryParam(); + if (next === "pm-saved") { + void (async () => { + try { + await ensureDefaultPaymentMethod(); + } catch { + // Webhook may already have promoted; list still refreshes below. + } + void reload(); + })(); + } else if (next === "succeeded") { + void reload(); + } + }, [ensureDefaultPaymentMethod, reload]); + + async function onTopUp() { + setError(null); + setBusy("topup"); + try { + const { checkoutUrl } = await startTopUp({ amountUsd: amountUsd.trim() }); + redirectToCheckout(checkoutUrl); + } catch (err) { + setError(err instanceof Error ? err.message : "Top-up failed"); + setBusy(null); + } + } + + async function onAddPaymentMethod() { + setError(null); + setBusy("pm"); + try { + const { checkoutUrl } = await startPaymentMethodCheckout(); + redirectToCheckout(checkoutUrl); + } catch (err) { + setError( + err instanceof Error ? err.message : "Payment method setup failed" + ); + setBusy(null); + } + } + + if (state.status === "loading" || state.status === "idle") { + return ( +
+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+

Could not load wallet.

+

{state.message}

+ +
+ ); + } + + const { wallet, paymentMethods, invoices } = state; + const usageUsd = formatWalletUsd(periodBillableUsdMicros); + const billingState = wallet.billingState; + const posture = spendPostureBadge(billingState.status); + const runway = availableRunway(billingState); + const included = includedUsageSummary(billingState); + const limitNote = overageLimitNote(billingState); + const defaultPm = + paymentMethods.find((pm) => pm.isDefault) ?? paymentMethods[0] ?? null; + const hasPaymentMethod = + wallet.paymentMethod.hasDefault ?? paymentMethods.length > 0; + + return ( +
+
+
+
+ + {posture.label} + +

+ {billingState.explain.headline} +

+
+

+ {billingState.explain.detail} +

+ +
+
+

+ Available +

+

+ {runway.usd} +

+ {runway.detail ? ( +

{runway.detail}

+ ) : null} + {limitNote ? ( +

{limitNote}

+ ) : null} +
+
+

+ Usage this period +

+

+ ${usageUsd} +

+
+
+ {included ? ( +

+ {includedUsageRemainingLabel(included)} + {included.resetsAt + ? ` · resets ${new Date(included.resetsAt).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + } + )}` + : ""} +

+ ) : null} +

+ {collectionSchedule(billingState)} +

+ {wallet.payPerUsePlans.map((plan) => ( +

+ {plan.planName}: {plan.resolvedBehavior} +

+ ))} +
+
+ {showTopUp ? ( +
+ $ + setAmountUsd(e.target.value)} + className="h-[30px] w-24 rounded-[4px] border border-hairline bg-dark-card px-2 font-mono text-[13px] tabular-nums text-fg outline-none focus-visible:ring-1 focus-visible:ring-green-bright/30" + aria-label="Top-up amount in USD" + /> + + +
+ ) : ( + + )} + {showTopUp ? ( +
+ {QUICK_AMOUNTS.map((preset) => ( + + ))} +
+ ) : null} +
+
+ + {flash === "succeeded" ? ( +

+ Funds added. Your balance updates once Stripe settles the payment. +

+ ) : null} + {flash === "pm-saved" ? ( +

+ Payment method saved. +

+ ) : null} + {flash === "canceled" ? ( +

+ Checkout canceled. +

+ ) : null} + +
+
+

+ Payment method for usage billing +

+

+ {hasPaymentMethod && defaultPm + ? `${defaultPm.brand ?? defaultPm.type}${defaultPm.last4 ? ` •••• ${defaultPm.last4}` : ""}` + : hasPaymentMethod + ? "Payment method on file." + : "No payment method on file — progressive invoices cannot charge once credits run out."} +

+
+ +
+ +
+

Billing history

+ {invoices.length === 0 ? ( +

+ No invoices or top-ups yet. +

+ ) : ( +
    + {invoices.slice(0, 8).map((invoice) => ( +
  • + + {invoice.number ?? invoice.id} + + + {formatInvoiceDate(invoice.issuedAt ?? invoice.periodEnd)} + + + {invoice.invoiceType === "auto_topup" + ? "top-up" + : invoice.status} + + + {invoice.totalAmount} {invoice.currency.toUpperCase()} + +
  • + ))} +
+ )} +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/lib/console/pymthouse-billing-bff.ts b/lib/console/pymthouse-billing-bff.ts index 2b74dfa..8036c98 100644 --- a/lib/console/pymthouse-billing-bff.ts +++ b/lib/console/pymthouse-billing-bff.ts @@ -3,16 +3,27 @@ import "server-only"; import { PmtHouseError, type BillingProduct, + type CreateAppUserPaymentMethodCheckoutResult, type CreateBillingCheckoutResult, type UserSubscriptionResponse, } from "@pymthouse/builder-sdk"; import { createPmtHouseClientForPublicApp } from "@/lib/console/pymthouse-bff"; import type { DashboardBillingPlan, + DashboardInvoice, + DashboardInvoiceHostedUrl, + DashboardPaymentMethod, DashboardScheduledChangeConflict, DashboardSubscriptionChange, DashboardUserSubscription, } from "@/lib/console/pymthouse-billing"; +import type { + DashboardOwnerWallet, + DashboardWalletInvoice, + DashboardWalletPaymentMethod, + DashboardWalletPaymentMethodCheckoutResult, + DashboardWalletTopUpResult, +} from "@/lib/console/pymthouse-wallet"; import { pymthouseAppsOrigin, readM2mAuthHeader, @@ -22,11 +33,23 @@ import { export type { DashboardBillingPlan, + DashboardInvoice, + DashboardInvoiceHostedUrl, + DashboardPaymentMethod, DashboardScheduledChangeConflict, DashboardSubscriptionChange, + DashboardSubscriptionHistoryItem, DashboardUserSubscription, } from "@/lib/console/pymthouse-billing"; +export type { + DashboardOwnerWallet, + DashboardWalletInvoice, + DashboardWalletPaymentMethod, + DashboardWalletPaymentMethodCheckoutResult, + DashboardWalletTopUpResult, +} from "@/lib/console/pymthouse-wallet"; + function readOptionalString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -193,3 +216,209 @@ export async function listDashboardUserSubscriptions(externalUserId: string) { const client = createPmtHouseClientForPublicApp(readPublicClientId()); return client.listUserSubscriptions(externalUserId); } + +export async function listDashboardUserInvoices( + externalUserId: string, + opts?: { page?: number; pageSize?: number } +): Promise<{ + items: DashboardInvoice[]; + page: number; + pageSize: number; + totalCount: number; +}> { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.listUserInvoices(externalUserId, opts); +} + +export async function getDashboardUserInvoiceHostedUrl( + externalUserId: string, + invoiceId: string +): Promise { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.getUserInvoiceHostedUrl(externalUserId, invoiceId); +} + +export async function listDashboardUserPaymentMethods( + externalUserId: string +): Promise { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + const result = await client.listUserPaymentMethods(externalUserId); + return result.paymentMethods ?? []; +} + +export async function startDashboardPaymentMethodCheckout(input: { + externalUserId: string; + successUrl?: string; + cancelUrl?: string; +}): Promise { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.createUserPaymentMethodCheckout({ + externalUserId: input.externalUserId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }); +} + +export async function setDashboardUserDefaultPaymentMethod( + externalUserId: string, + paymentMethodId: string +) { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.setUserDefaultPaymentMethod(externalUserId, paymentMethodId); +} + +/** Promote first attached PM to Stripe default when none is set (post-Checkout). */ +export async function ensureDashboardUserDefaultPaymentMethod( + externalUserId: string +) { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.ensureUserDefaultPaymentMethod(externalUserId); +} + +export async function removeDashboardUserPaymentMethod( + externalUserId: string, + paymentMethodId: string +) { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.unlinkUserPaymentMethod(externalUserId, paymentMethodId); +} + +// --------------------------------------------------------------------------- +// Owner wallet (Builder M2M) — pymthouse PR #399 +// /api/v1/apps/{clientId}/billing/wallet* over M2M Basic auth. +// --------------------------------------------------------------------------- + +async function walletFetch( + path: string, + init?: { + method?: string; + body?: Record; + externalUserId?: string; + query?: Record; + } +): Promise { + const publicClientId = readPublicClientId(); + const params = new URLSearchParams(); + const externalUserId = init?.externalUserId?.trim(); + if (externalUserId && (init?.method ?? "GET") === "GET") { + params.set("externalUserId", externalUserId); + } + for (const [key, value] of Object.entries(init?.query ?? {})) { + if (value === undefined) continue; + params.set(key, String(value)); + } + const query = params.toString(); + const separator = path.includes("?") ? "&" : "?"; + const urlPath = `${path}${query ? `${separator}${query}` : ""}`; + + const method = init?.method ?? "GET"; + const body = + init?.body || + (externalUserId && (method === "POST" || method === "PATCH")) + ? { + ...(init?.body ?? {}), + ...(externalUserId && (method === "POST" || method === "PATCH") + ? { externalUserId } + : {}), + } + : undefined; + + const response = await fetch( + `${pymthouseAppsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/billing/wallet${urlPath}`, + { + method, + headers: { + Authorization: readM2mAuthHeader(), + Accept: "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + cache: "no-store", + } + ); + return readPymthouseResponse(response); +} + +export async function getDashboardOwnerWallet( + externalUserId: string +): Promise { + return walletFetch("", { + externalUserId, + }); +} + +export async function startDashboardWalletTopUp(input: { + amountUsd: string; + externalUserId: string; + successUrl?: string; + cancelUrl?: string; +}): Promise { + return walletFetch("/top-up", { + method: "POST", + externalUserId: input.externalUserId, + body: { + amountUsd: input.amountUsd, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }, + }); +} + +export async function listDashboardWalletInvoices(opts: { + externalUserId: string; + page?: number; + pageSize?: number; +}): Promise<{ + items: DashboardWalletInvoice[]; + page: number; + pageSize: number; + totalCount: number; +}> { + return walletFetch(`/invoices`, { + externalUserId: opts.externalUserId, + query: { + page: opts.page, + pageSize: opts.pageSize, + }, + }); +} + +export async function listDashboardWalletPaymentMethods( + externalUserId: string +): Promise { + const result = await walletFetch<{ + paymentMethods?: DashboardWalletPaymentMethod[]; + }>("/payment-methods", { externalUserId }); + return result.paymentMethods ?? []; +} + +export async function startDashboardWalletPaymentMethodCheckout(input: { + externalUserId: string; + successUrl?: string; + cancelUrl?: string; +}): Promise { + return walletFetch( + "/payment-methods", + { + method: "POST", + externalUserId: input.externalUserId, + body: { + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }, + } + ); +} + +export async function ensureDashboardWalletDefaultPaymentMethod( + externalUserId: string +): Promise<{ promoted: boolean; paymentMethodId: string | null }> { + return walletFetch<{ promoted: boolean; paymentMethodId: string | null }>( + "/payment-methods", + { + method: "PATCH", + externalUserId, + body: { ensureDefault: true }, + } + ); +} diff --git a/lib/console/pymthouse-billing.ts b/lib/console/pymthouse-billing.ts index c241634..547d88f 100644 --- a/lib/console/pymthouse-billing.ts +++ b/lib/console/pymthouse-billing.ts @@ -34,6 +34,44 @@ export type DashboardScheduledChangeConflict = { scheduledActiveFrom: string | null; }; +export type DashboardInvoice = { + id: string; + number?: string; + status: string; + currency: string; + totalAmount: string; + issuedAt?: string; + periodStart?: string; + periodEnd?: string; + invoiceType?: string; +}; + +export type DashboardPaymentMethod = { + id: string; + type: string; + brand: string | null; + last4: string | null; + expMonth: number | null; + expYear: number | null; + isDefault: boolean; +}; + +export type DashboardSubscriptionHistoryItem = { + id: string; + status: string; + current: boolean; + planId: string | null; + planKey: string | null; + planName: string | null; + activeFrom: string | null; + activeTo: string | null; +}; + +export type DashboardInvoiceHostedUrl = { + hostedInvoiceUrl: string | null; + invoicePdf: string | null; +}; + export type DashboardUserSubscription = { planId: string | null; planName: string | null; diff --git a/lib/console/pymthouse-wallet.ts b/lib/console/pymthouse-wallet.ts new file mode 100644 index 0000000..3a62cfb --- /dev/null +++ b/lib/console/pymthouse-wallet.ts @@ -0,0 +1,60 @@ +import type { BillingState } from "@pymthouse/builder-sdk"; + +export type DashboardWalletBalance = { + usdMicros: string; + usd: string; + lifetimeGrantedUsdMicros: string; + consumedUsdMicros: string; +}; + +export type DashboardWalletPayPerUsePlan = { + planId: string; + planName: string; + chargeThresholdUsdMicros: string | null; + resolvedBehavior: string; +}; + +export type DashboardOwnerWallet = { + clientId: string; + balance: DashboardWalletBalance | null; + paymentMethod: { + /** null = provider state unknown (fail open upstream). */ + hasDefault: boolean | null; + }; + billingState: BillingState; + payPerUsePlans: DashboardWalletPayPerUsePlan[]; +}; + +export type DashboardWalletInvoice = { + id: string; + number?: string; + status: string; + currency: string; + totalAmount: string; + issuedAt?: string; + periodStart?: string; + periodEnd?: string; + invoiceType?: string; +}; + +export type DashboardWalletPaymentMethod = { + id: string; + type: string; + brand: string | null; + last4: string | null; + expMonth: number | null; + expYear: number | null; + isDefault: boolean; +}; + +export type DashboardWalletTopUpResult = { + checkoutUrl: string; + sessionId: string | null; + amountUsdMicros: string; +}; + +export type DashboardWalletPaymentMethodCheckoutResult = { + checkoutUrl: string; + sessionId: string | null; + hasDefaultPaymentMethod: boolean; +}; diff --git a/lib/console/useBillingAccount.test.ts b/lib/console/useBillingAccount.test.ts new file mode 100644 index 0000000..5348c54 --- /dev/null +++ b/lib/console/useBillingAccount.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { isSoftBillingListUnavailable } from "./useBillingAccount"; + +test("503 Billing unavailable is a soft empty list, not a hard UI failure", () => { + assert.equal(isSoftBillingListUnavailable(503, "Billing unavailable"), true); + assert.equal(isSoftBillingListUnavailable(503, undefined), true); + assert.equal(isSoftBillingListUnavailable(503, " Billing unavailable "), true); +}); + +test("other statuses and messages remain hard failures", () => { + assert.equal(isSoftBillingListUnavailable(500, "Billing unavailable"), false); + assert.equal(isSoftBillingListUnavailable(502, "upstream timeout"), false); + assert.equal(isSoftBillingListUnavailable(404, "not found"), false); + assert.equal(isSoftBillingListUnavailable(503, "rate limited"), false); +}); diff --git a/lib/console/useBillingAccount.ts b/lib/console/useBillingAccount.ts index e6f6ac5..2781b1e 100644 --- a/lib/console/useBillingAccount.ts +++ b/lib/console/useBillingAccount.ts @@ -1,46 +1,298 @@ "use client"; -/** Placeholder until the wallet/payment-methods PR replaces this hook. */ -export function useBillingAccount(_enabled: boolean): { - state: { - status: "idle" | "loading" | "ready" | "error"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - paymentMethods: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - invoices: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - subscriptions: any[]; - paymentMethodsError: null; - invoicesError: null; - subscriptionsError: null; - message?: string; - }; - reload: () => Promise; - startPaymentMethodCheckout: ( - input?: unknown - ) => Promise<{ checkoutUrl: string }>; - openInvoice: ( - input: unknown - ) => Promise<{ hostedInvoiceUrl: string; invoicePdf: string }>; - setDefaultPaymentMethod: (input: unknown) => Promise; - ensureDefaultPaymentMethod: (input?: unknown) => Promise; - removePaymentMethod: (input: unknown) => Promise; -} { - return { - state: { +import { useCallback, useEffect, useState } from "react"; +import type { + DashboardInvoice, + DashboardInvoiceHostedUrl, + DashboardPaymentMethod, + DashboardSubscriptionHistoryItem, +} from "@/lib/console/pymthouse-billing"; +import { readResponseJson } from "@/lib/console/read-response-json"; + +/** Map missing/unroutable upstream billing APIs to an actionable message. */ +function billingUpstreamMessage( + surface: "payment methods" | "invoices" | "subscriptions", + status: number, + upstreamError?: string +): string { + if (status === 404 || status === 405) { + if (surface === "subscriptions") { + return ( + `PymtHouse end-user subscriptions API is unavailable (${status}). ` + + "Deploy the /users/{id}/subscriptions route to this environment." + ); + } + return ( + `PymtHouse end-user ${surface} API is unavailable (${status}). ` + + "Deploy the /users/{id}/payment-methods and /users/{id}/invoices routes " + + "(pymthouse PR #386) to this environment." + ); + } + return upstreamError ?? `${surface} failed (${status})`; +} + +/** + * List endpoints may answer 503 "Billing unavailable" for expected empty + * conditions (no Stripe customer yet, sandbox Connect not ready, OM admin + * client offline). Those are empty states, not hard UI failures. + */ +export function isSoftBillingListUnavailable( + status: number, + upstreamError?: string +): boolean { + if (status !== 503) return false; + const msg = upstreamError?.trim().toLowerCase() ?? ""; + return !msg || msg === "billing unavailable"; +} + +type BillingAccountState = + | { status: "idle" } + | { status: "loading" } + | { + status: "ready"; + paymentMethods: DashboardPaymentMethod[]; + invoices: DashboardInvoice[]; + subscriptions: DashboardSubscriptionHistoryItem[]; + paymentMethodsError: string | null; + invoicesError: string | null; + subscriptionsError: string | null; + }; + +type ListLoadResult = { + items: T[]; + error: string | null; +}; + +async function loadPaymentMethods(): Promise< + ListLoadResult +> { + try { + const response = await fetch("/api/pymthouse/payment-methods"); + const body = await readResponseJson<{ + paymentMethods?: DashboardPaymentMethod[]; + error?: string; + }>(response); + if (!response.ok) { + if (isSoftBillingListUnavailable(response.status, body.error)) { + return { items: [], error: null }; + } + return { + items: [], + error: billingUpstreamMessage( + "payment methods", + response.status, + body.error + ), + }; + } + return { items: body.paymentMethods ?? [], error: null }; + } catch (error) { + return { + items: [], + error: + error instanceof Error + ? error.message + : "Failed to load payment methods", + }; + } +} + +async function loadInvoices(): Promise> { + try { + const response = await fetch("/api/pymthouse/invoices?pageSize=20"); + const body = await readResponseJson<{ + items?: DashboardInvoice[]; + error?: string; + }>(response); + if (!response.ok) { + if (isSoftBillingListUnavailable(response.status, body.error)) { + return { items: [], error: null }; + } + return { + items: [], + error: billingUpstreamMessage("invoices", response.status, body.error), + }; + } + return { items: body.items ?? [], error: null }; + } catch (error) { + return { + items: [], + error: error instanceof Error ? error.message : "Failed to load invoices", + }; + } +} + +async function loadSubscriptions(): Promise< + ListLoadResult +> { + try { + const response = await fetch("/api/pymthouse/subscriptions"); + const body = await readResponseJson<{ + items?: DashboardSubscriptionHistoryItem[]; + error?: string; + }>(response); + if (!response.ok) { + if (isSoftBillingListUnavailable(response.status, body.error)) { + return { items: [], error: null }; + } + return { + items: [], + error: billingUpstreamMessage( + "subscriptions", + response.status, + body.error + ), + }; + } + return { items: body.items ?? [], error: null }; + } catch (error) { + return { + items: [], + error: + error instanceof Error + ? error.message + : "Failed to load subscription history", + }; + } +} + +export function useBillingAccount(enabled: boolean) { + const [state, setState] = useState({ status: "idle" }); + + const load = useCallback(async () => { + if (!enabled) { + setState({ + status: "ready", + paymentMethods: [], + invoices: [], + subscriptions: [], + paymentMethodsError: null, + invoicesError: null, + subscriptionsError: null, + }); + return; + } + + setState({ status: "loading" }); + const [pm, inv, subs] = await Promise.all([ + loadPaymentMethods(), + loadInvoices(), + loadSubscriptions(), + ]); + setState({ status: "ready", - paymentMethods: [], - invoices: [], - subscriptions: [], - paymentMethodsError: null, - invoicesError: null, - subscriptionsError: null, + paymentMethods: pm.items, + invoices: inv.items, + subscriptions: subs.items, + paymentMethodsError: pm.error, + invoicesError: inv.error, + subscriptionsError: subs.error, + }); + }, [enabled]); + + useEffect(() => { + void load(); + }, [load]); + + const startPaymentMethodCheckout = useCallback( + async (input?: { successUrl?: string; cancelUrl?: string }) => { + const response = await fetch("/api/pymthouse/payment-methods", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input ?? {}), + }); + const body = await readResponseJson<{ + checkoutUrl?: string; + error?: string; + }>(response); + if (!response.ok || !body.checkoutUrl) { + throw new Error( + body.error ?? `Payment method checkout failed (${response.status})` + ); + } + return { checkoutUrl: body.checkoutUrl }; + }, + [] + ); + + const openInvoice = useCallback( + async (input: { invoiceId: string }): Promise => { + const response = await fetch( + `/api/pymthouse/invoices/${encodeURIComponent(input.invoiceId)}/hosted-url` + ); + const body = await readResponseJson< + DashboardInvoiceHostedUrl & { error?: string } + >(response); + if (!response.ok) { + throw new Error(body.error ?? `Invoice link failed (${response.status})`); + } + return { + hostedInvoiceUrl: body.hostedInvoiceUrl ?? null, + invoicePdf: body.invoicePdf ?? null, + }; }, - reload: async () => {}, - startPaymentMethodCheckout: async () => ({ checkoutUrl: "" }), - openInvoice: async () => ({ hostedInvoiceUrl: "", invoicePdf: "" }), - setDefaultPaymentMethod: async () => {}, - ensureDefaultPaymentMethod: async () => {}, - removePaymentMethod: async () => {}, + [] + ); + + const setDefaultPaymentMethod = useCallback( + async (input: { paymentMethodId: string }) => { + const response = await fetch("/api/pymthouse/payment-methods", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paymentMethodId: input.paymentMethodId }), + }); + const body = await readResponseJson<{ error?: string }>(response); + if (!response.ok) { + throw new Error( + body.error ?? `Set default payment method failed (${response.status})` + ); + } + await load(); + }, + [load] + ); + + const ensureDefaultPaymentMethod = useCallback(async () => { + const response = await fetch("/api/pymthouse/payment-methods", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ensureDefault: true }), + }); + const body = await readResponseJson<{ error?: string }>(response); + if (!response.ok) { + throw new Error( + body.error ?? + `Ensure default payment method failed (${response.status})` + ); + } + await load(); + }, [load]); + + const removePaymentMethod = useCallback( + async (input: { paymentMethodId: string }) => { + const response = await fetch("/api/pymthouse/payment-methods", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paymentMethodId: input.paymentMethodId }), + }); + const body = await readResponseJson<{ error?: string }>(response); + if (!response.ok) { + throw new Error( + body.error ?? `Remove payment method failed (${response.status})` + ); + } + await load(); + }, + [load] + ); + + return { + state, + reload: load, + startPaymentMethodCheckout, + openInvoice, + setDefaultPaymentMethod, + ensureDefaultPaymentMethod, + removePaymentMethod, }; } diff --git a/lib/console/useOwnerWallet.ts b/lib/console/useOwnerWallet.ts index 9e4462d..bf5ffac 100644 --- a/lib/console/useOwnerWallet.ts +++ b/lib/console/useOwnerWallet.ts @@ -1,12 +1,205 @@ "use client"; -/** Placeholder until the wallet PR replaces this hook. */ -export function useWalletBillingState(_enabled: boolean): { - state: - | { status: "idle" } - | { status: "loading" } - | { status: "ready"; wallet: { billingState: unknown } } - | { status: "error"; message: string }; -} { - return { state: { status: "idle" } }; +import { useCallback, useEffect, useState } from "react"; +import type { + DashboardOwnerWallet, + DashboardWalletInvoice, + DashboardWalletPaymentMethod, +} from "@/lib/console/pymthouse-wallet"; +import { readResponseJson } from "@/lib/console/read-response-json"; + +/** Map missing/unroutable upstream wallet APIs to an actionable message. */ +function walletUpstreamMessage(status: number, upstreamError?: string): string { + if (status === 404 || status === 405) { + return ( + `PymtHouse owner wallet API is unavailable (${status}). ` + + "Deploy the /apps/{clientId}/billing/wallet routes (pymthouse PR #399) " + + "to this environment and check the M2M client credentials." + ); + } + return upstreamError ?? `Wallet request failed (${status})`; +} + +type OwnerWalletState = + | { status: "idle" } + | { status: "loading" } + | { + status: "ready"; + wallet: DashboardOwnerWallet; + paymentMethods: DashboardWalletPaymentMethod[]; + invoices: DashboardWalletInvoice[]; + } + | { status: "error"; message: string }; + +type WalletBillingState = + | { status: "idle" } + | { status: "loading" } + | { status: "ready"; wallet: DashboardOwnerWallet } + | { status: "error"; message: string }; + +/** Wallet GET only — remaining included usage + plan, without PM/invoice lists. */ +export function useWalletBillingState(enabled: boolean) { + const [state, setState] = useState({ status: "idle" }); + + const load = useCallback(async () => { + if (!enabled) { + setState({ status: "idle" }); + return; + } + + setState({ status: "loading" }); + try { + const walletResponse = await fetch("/api/pymthouse/wallet"); + const walletBody = await readResponseJson< + DashboardOwnerWallet & { error?: string } + >(walletResponse); + if (!walletResponse.ok) { + throw new Error( + walletUpstreamMessage(walletResponse.status, walletBody.error) + ); + } + setState({ status: "ready", wallet: walletBody }); + } catch (error) { + setState({ + status: "error", + message: + error instanceof Error ? error.message : "Failed to load wallet", + }); + } + }, [enabled]); + + useEffect(() => { + void load(); + }, [load]); + + return { state, reload: load }; +} + +export function useOwnerWallet(enabled: boolean) { + const [state, setState] = useState({ status: "idle" }); + + const load = useCallback(async () => { + if (!enabled) { + setState({ status: "idle" }); + return; + } + + setState({ status: "loading" }); + try { + const [walletResponse, pmResponse, invResponse] = await Promise.all([ + fetch("/api/pymthouse/wallet"), + fetch("/api/pymthouse/wallet/payment-methods"), + fetch("/api/pymthouse/wallet/invoices?pageSize=20"), + ]); + + const walletBody = await readResponseJson< + DashboardOwnerWallet & { error?: string } + >(walletResponse); + if (!walletResponse.ok) { + throw new Error( + walletUpstreamMessage(walletResponse.status, walletBody.error) + ); + } + + const pmBody = await readResponseJson<{ + paymentMethods?: DashboardWalletPaymentMethod[]; + error?: string; + }>(pmResponse); + if (!pmResponse.ok) { + throw new Error(walletUpstreamMessage(pmResponse.status, pmBody.error)); + } + + const invBody = await readResponseJson<{ + items?: DashboardWalletInvoice[]; + error?: string; + }>(invResponse); + if (!invResponse.ok) { + throw new Error( + walletUpstreamMessage(invResponse.status, invBody.error) + ); + } + + setState({ + status: "ready", + wallet: walletBody, + paymentMethods: pmBody.paymentMethods ?? [], + invoices: invBody.items ?? [], + }); + } catch (error) { + setState({ + status: "error", + message: + error instanceof Error ? error.message : "Failed to load wallet", + }); + } + }, [enabled]); + + useEffect(() => { + void load(); + }, [load]); + + const startTopUp = useCallback(async (input: { amountUsd: string }) => { + const response = await fetch("/api/pymthouse/wallet/top-up", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amountUsd: input.amountUsd, + successUrl: `${window.location.origin}/usage?topup=succeeded`, + cancelUrl: `${window.location.origin}/usage?topup=canceled`, + }), + }); + const body = await readResponseJson<{ + checkoutUrl?: string; + error?: string; + }>(response); + if (!response.ok || !body.checkoutUrl) { + throw new Error(body.error ?? `Top-up failed (${response.status})`); + } + return { checkoutUrl: body.checkoutUrl }; + }, []); + + const startPaymentMethodCheckout = useCallback(async () => { + const response = await fetch("/api/pymthouse/wallet/payment-methods", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + successUrl: `${window.location.origin}/usage?topup=pm-saved`, + cancelUrl: `${window.location.origin}/usage?topup=canceled`, + }), + }); + const body = await readResponseJson<{ + checkoutUrl?: string; + error?: string; + }>(response); + if (!response.ok || !body.checkoutUrl) { + throw new Error( + body.error ?? `Payment method checkout failed (${response.status})` + ); + } + return { checkoutUrl: body.checkoutUrl }; + }, []); + + const ensureDefaultPaymentMethod = useCallback(async () => { + const response = await fetch("/api/pymthouse/wallet/payment-methods", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ensureDefault: true }), + }); + const body = await readResponseJson<{ error?: string }>(response); + if (!response.ok) { + throw new Error( + body.error ?? + `Ensure default payment method failed (${response.status})` + ); + } + await load(); + }, [load]); + + return { + state, + reload: load, + startTopUp, + startPaymentMethodCheckout, + ensureDefaultPaymentMethod, + }; } diff --git a/lib/console/wallet-settlement-display.test.ts b/lib/console/wallet-settlement-display.test.ts new file mode 100644 index 0000000..7e588b0 --- /dev/null +++ b/lib/console/wallet-settlement-display.test.ts @@ -0,0 +1,307 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { BillingState } from "@pymthouse/builder-sdk"; +import type { BillingStateWithIncluded } from "./wallet-settlement-display"; +import { + availableRunway, + collectionSchedule, + includedUsageRemainingLabel, + includedUsageSummary, + overageLimitNote, + spendPostureBadge, +} from "./wallet-settlement-display"; + +function money(usdMicros: string, usd: string) { + return { usdMicros, usd, currency: "USD" }; +} + +function makeState(overrides: { + status?: BillingState["status"]; + includedRemaining?: { usdMicros: string; usd: string }; + includedTotal?: { usdMicros: string; usd: string }; + includedConsumed?: { usdMicros: string; usd: string }; + sourcePlan?: { id: string | null; name: string | null; type: string | null } | null; + prepaid?: { usdMicros: string; usd: string }; + spendable?: { usdMicros: string; usd: string }; + ceiling?: { usdMicros: string; usd: string }; + unbilledDebt?: { usdMicros: string; usd: string } | null; + remaining?: { usdMicros: string; usd: string } | null; + utilizationBps?: number | null; + leadThreshold?: { usdMicros: string; usd: string }; +}): BillingStateWithIncluded { + const prepaid = { + ...money("0", "0.00"), + ...(overrides.prepaid ?? {}), + }; + const includedRemaining = { + ...money("0", "0.00"), + ...(overrides.includedRemaining ?? {}), + }; + const spendableDefaultMicros = ( + BigInt(prepaid.usdMicros || "0") + BigInt(includedRemaining.usdMicros || "0") + ).toString(); + const spendable = { + ...money(spendableDefaultMicros, "0.00"), + ...(overrides.spendable ?? {}), + }; + return { + asOf: "2026-08-08T00:00:00.000Z", + subject: { + type: "owner", + externalUserId: null, + billingMode: "owner_rollup", + }, + status: overrides.status ?? "overage", + canSpend: true, + reason: null, + funding: { + prepaid, + included: includedRemaining, + includedUsage: { + total: { + ...money("10000000", "10.00"), + ...(overrides.includedTotal ?? {}), + }, + remaining: includedRemaining, + consumed: { + ...money("0", "0.00"), + ...(overrides.includedConsumed ?? {}), + }, + resetsAt: "2026-09-01T00:00:00.000Z", + sourcePlan: overrides.sourcePlan === undefined ? null : overrides.sourcePlan, + }, + spendable, + overage: { + eligible: true, + ceiling: { + ...money("2000000", "2.00"), + ...(overrides.ceiling ?? {}), + }, + unbilledDebt: + overrides.unbilledDebt === undefined + ? money("500000", "0.50") + : overrides.unbilledDebt && { + ...money("0", "0.00"), + ...overrides.unbilledDebt, + }, + remaining: + overrides.remaining === undefined + ? money("1500000", "1.50") + : overrides.remaining && { + ...money("0", "0.00"), + ...overrides.remaining, + }, + utilizationBps: + overrides.utilizationBps === undefined + ? 2500 + : overrides.utilizationBps, + debtSource: "gathering_invoice", + }, + }, + collection: { + mode: "progressive_invoice", + collector: "openmeter_stripe", + paymentMethod: { hasDefault: true, brand: "visa", last4: "4242" }, + nextAction: "none", + leadThreshold: { + ...money("1000000", "1.00"), + ...(overrides.leadThreshold ?? {}), + }, + minimumCharge: money("500000", "0.50"), + cycle: "MONTH", + collectionInterval: "DAY", + lastRaisedAt: null, + nextRaiseEligibleAt: null, + }, + explain: { headline: "", detail: "", docsUrl: "" }, + }; +} + +describe("spendPostureBadge", () => { + it("colours each posture distinctly", () => { + assert.deepEqual(spendPostureBadge("active"), { + label: "Credits", + tone: "ok", + }); + assert.equal(spendPostureBadge("overage").tone, "info"); + assert.equal(spendPostureBadge("at_risk").tone, "warn"); + assert.equal(spendPostureBadge("blocked").tone, "danger"); + }); +}); + +describe("availableRunway", () => { + it("sums included and prepaid when funded", () => { + const runway = availableRunway( + makeState({ + status: "active", + includedRemaining: { usdMicros: "8000000", usd: "8.00" }, + prepaid: { usdMicros: "2500000", usd: "2.50" }, + unbilledDebt: null, + }), + ); + assert.equal(runway.usd, "$10.50"); + assert.equal(runway.usdMicros, "10500000"); + assert.equal(runway.tone, "ok"); + assert.equal(runway.detail, "Included $8.00 · Credits $2.50"); + }); + + it("names the live plan on the included side", () => { + const runway = availableRunway( + makeState({ + status: "active", + includedRemaining: { usdMicros: "4980000", usd: "4.98" }, + prepaid: { usdMicros: "10000000", usd: "10.00" }, + unbilledDebt: null, + sourcePlan: { id: "starter", name: "Starter", type: "free" }, + }), + ); + assert.equal(runway.detail, "Starter included $4.98 · Credits $10.00"); + }); + + it("omits zero sides from the funded breakdown", () => { + const runway = availableRunway( + makeState({ + status: "active", + includedRemaining: { usdMicros: "5000000", usd: "5.00" }, + prepaid: { usdMicros: "0", usd: "0.00" }, + unbilledDebt: null, + }), + ); + assert.equal(runway.usd, "$5.00"); + assert.equal(runway.detail, "Included $5.00"); + }); + + it("goes negative once unbilled debt exceeds funding", () => { + const runway = availableRunway( + makeState({ + status: "overage", + includedRemaining: { usdMicros: "0", usd: "0.00" }, + prepaid: { usdMicros: "0", usd: "0.00" }, + unbilledDebt: { usdMicros: "1250000", usd: "1.25" }, + }), + ); + assert.equal(runway.usd, "-$1.25"); + assert.equal(runway.usdMicros, "-1250000"); + assert.equal(runway.tone, "info"); + assert.equal(runway.detail, "Unbilled $1.25"); + }); + + it("keeps spendable while funded even if gathering debt is present", () => { + const runway = availableRunway( + makeState({ + status: "active", + includedRemaining: { usdMicros: "0", usd: "0.00" }, + prepaid: { usdMicros: "5010000", usd: "5.01" }, + unbilledDebt: { usdMicros: "19990000", usd: "19.99" }, + }), + ); + assert.equal(runway.usd, "$5.01"); + assert.equal(runway.usdMicros, "5010000"); + assert.equal(runway.detail, "Credits $5.01"); + }); + + it("treats null debt as zero", () => { + const runway = availableRunway( + makeState({ + status: "active", + includedRemaining: { usdMicros: "1000000", usd: "1.00" }, + unbilledDebt: null, + }), + ); + assert.equal(runway.usd, "$1.00"); + }); + + it("uses danger tone when blocked below zero", () => { + const runway = availableRunway( + makeState({ + status: "blocked", + includedRemaining: { usdMicros: "0", usd: "0.00" }, + unbilledDebt: { usdMicros: "2000000", usd: "2.00" }, + }), + ); + assert.equal(runway.usd, "-$2.00"); + assert.equal(runway.tone, "danger"); + }); +}); + +describe("includedUsageSummary", () => { + it("returns null when the plan has no included allowance", () => { + const summary = includedUsageSummary( + makeState({ + includedRemaining: { usdMicros: "0", usd: "0.00" }, + includedTotal: { usdMicros: "0", usd: "0.00" }, + }), + ); + assert.equal(summary, null); + }); + + it("keeps remaining after the prepaid balance and names the plan", () => { + const summary = includedUsageSummary( + makeState({ + includedRemaining: { usdMicros: "4982000", usd: "4.98" }, + includedTotal: { usdMicros: "5000000", usd: "5.00" }, + includedConsumed: { usdMicros: "18000", usd: "0.02" }, + sourcePlan: { id: "plan_1", name: "Starter", type: "free" }, + }), + ); + assert.ok(summary); + assert.equal(summary.remainingUsd, "4.98"); + assert.equal(summary.totalUsd, "5.00"); + assert.equal(summary.consumedUsd, "0.02"); + assert.equal(summary.planName, "Starter"); + assert.equal( + includedUsageRemainingLabel(summary), + "Starter · $4.98 of $5.00 included left", + ); + }); +}); + +describe("overageLimitNote", () => { + it("shows ceiling and remaining headroom while spendable", () => { + const note = overageLimitNote(makeState({})); + assert.equal(note, "Overage limit $2.00 · $1.50 left"); + }); + + it("drops headroom when remaining is zero but not blocked", () => { + const note = overageLimitNote( + makeState({ + remaining: { usdMicros: "0", usd: "0.00" }, + }), + ); + assert.equal(note, "Overage limit $2.00"); + }); + + it("says the limit is reached when blocked", () => { + const note = overageLimitNote( + makeState({ + status: "blocked", + unbilledDebt: { usdMicros: "2000000", usd: "2.00" }, + remaining: { usdMicros: "0", usd: "0.00" }, + }), + ); + assert.equal(note, "Overage limit reached"); + }); + + it("hides the note when there is no ceiling", () => { + const note = overageLimitNote( + makeState({ ceiling: { usdMicros: "0", usd: "0.00" } }), + ); + assert.equal(note, null); + }); +}); + +describe("collectionSchedule", () => { + it("names the amount trigger and the recurring sweep", () => { + const copy = collectionSchedule(makeState({})); + assert.match(copy, /\$1\.00/); + assert.match(copy, /at least once a day/); + }); + + it("falls back to the sweep alone with no amount trigger", () => { + const copy = collectionSchedule( + makeState({ leadThreshold: { usdMicros: "0", usd: "0.00" } }), + ); + assert.equal(copy, "Usage is invoiced every day."); + }); +}); diff --git a/lib/console/wallet-settlement-display.ts b/lib/console/wallet-settlement-display.ts index 4a34d2f..db95d08 100644 --- a/lib/console/wallet-settlement-display.ts +++ b/lib/console/wallet-settlement-display.ts @@ -1,22 +1,211 @@ +import type { BillingState, BillingStatus } from "@pymthouse/builder-sdk"; +import { microsToUsd } from "./usage-capability-display"; + +type IncludedUsageFunding = { + total: { usdMicros: string; usd: string }; + remaining: { usdMicros: string; usd: string }; + consumed: { usdMicros: string; usd: string }; + resetsAt?: string; + sourcePlan?: { + id: string | null; + name: string | null; + type: string | null; + } | null; +}; + +/** Wallet payloads include this; builder-sdk 0.6.x types omit it. */ +export type BillingStateWithIncluded = BillingState & { + funding: BillingState["funding"] & { + includedUsage?: IncludedUsageFunding; + }; +}; + +function asIncludedState(state: BillingState): BillingStateWithIncluded { + return state as BillingStateWithIncluded; +} + +/** Wallet strip amounts: always two decimals (matches prepaid `$0.00`). */ +export function formatWalletUsd(micros: string | null | undefined): string { + if (!micros?.trim()) return "0.00"; + return microsToUsd(micros).toFixed(2); +} + +function parseUsdMicros(raw: string | null | undefined): bigint { + const trimmed = raw?.trim(); + if (!trimmed || !/^-?\d+$/.test(trimmed)) return BigInt(0); + try { + return BigInt(trimmed); + } catch { + return BigInt(0); + } +} + +/** Signed wallet dollars with an explicit minus (`-$1.25` / `$0.00`). */ +export function formatSignedWalletUsd(micros: bigint): string { + const negative = micros < BigInt(0); + const abs = negative ? -micros : micros; + const formatted = formatWalletUsd(abs.toString()); + return negative ? `-$${formatted}` : `$${formatted}`; +} + +export type SpendPostureTone = "ok" | "info" | "warn" | "danger"; + +/** + * Short badge for the spend posture. The long-form copy comes from + * `billingState.explain`, which the API owns so every surface says the same + * thing; the dashboard only picks the label and the colour. + */ +export function spendPostureBadge(status: BillingStatus): { + label: string; + tone: SpendPostureTone; +} { + switch (status) { + case "active": + return { label: "Credits", tone: "ok" }; + case "overage": + return { label: "Pay as you go", tone: "info" }; + case "at_risk": + return { label: "Collecting payment", tone: "warn" }; + case "blocked": + return { label: "Paused", tone: "danger" }; + } +} + +export type AvailableRunway = { + usdMicros: string; + /** Display with `$` / `-$`. */ + usd: string; + tone: SpendPostureTone; + /** Breakdown under the big number, or null when both sides are zero. */ + detail: string | null; +}; + +/** + * Signed runway for the Available figure. + * + * While prepaid/included remain, runway is spendable — gathering invoice + * totals can still list prepaid-covered usage under credit_then_invoice and + * must not be subtracted again. Once spendable is exhausted, runway is the + * negative of unbilled overage debt. + */ +export function availableRunway(state: BillingState): AvailableRunway { + const funding = asIncludedState(state).funding; + const included = parseUsdMicros( + funding.includedUsage?.remaining.usdMicros ?? funding.included.usdMicros, + ); + const prepaid = parseUsdMicros(state.funding.prepaid.usdMicros); + const spendable = parseUsdMicros(state.funding.spendable.usdMicros); + const debt = parseUsdMicros(state.funding.overage.unbilledDebt?.usdMicros); + const available = spendable > BigInt(0) ? spendable : -debt; + + let tone: SpendPostureTone = "ok"; + if (available < BigInt(0)) { + if (state.status === "blocked") tone = "danger"; + else if (state.status === "at_risk") tone = "warn"; + else tone = "info"; + } + + let detail: string | null = null; + if (available < BigInt(0)) { + detail = `Unbilled $${formatWalletUsd(debt.toString())}`; + } else { + const parts: string[] = []; + if (included > BigInt(0)) { + const planName = funding.includedUsage?.sourcePlan?.name?.trim(); + parts.push( + planName + ? `${planName} included $${formatWalletUsd(included.toString())}` + : `Included $${formatWalletUsd(included.toString())}`, + ); + } + if (prepaid > BigInt(0)) { + parts.push(`Credits $${formatWalletUsd(prepaid.toString())}`); + } + detail = parts.length > 0 ? parts.join(" · ") : null; + } + + return { + usdMicros: available.toString(), + usd: formatSignedWalletUsd(available), + tone, + detail, + }; +} + +/** + * Small footnote for the soft overage ceiling. Null when unlimited (ceiling 0). + */ +export function overageLimitNote(state: BillingState): string | null { + const ceiling = state.funding.overage.ceiling; + if (!ceiling?.usdMicros || ceiling.usdMicros === "0") return null; + if (state.status === "blocked") return "Overage limit reached"; + const remaining = state.funding.overage.remaining; + if (remaining && remaining.usdMicros !== "0") { + return `Overage limit $${ceiling.usd} · $${remaining.usd} left`; + } + return `Overage limit $${ceiling.usd}`; +} + export type IncludedUsageSummary = { - planName?: string; - planId?: string; - consumedUsdMicros: string; - totalUsdMicros: string; remainingUsdMicros: string; - remainingUsd?: string; + totalUsdMicros: string; + consumedUsdMicros: string; + remainingUsd: string; totalUsd: string; - resetsAt?: string; + consumedUsd: string; + planId: string | null; + planName: string | null; + resetsAt: string | null; }; +/** + * Remaining included-usage discount for the live plan period. + * Null when the live plan has no usage allowance (prepaid / invoice only). + */ export function includedUsageSummary( - _billingState: unknown + state: BillingState | null | undefined, ): IncludedUsageSummary | null { - return null; + if (!state) return null; + const funding = asIncludedState(state).funding; + const included = funding.includedUsage; + const remainingUsdMicros = + included?.remaining.usdMicros ?? funding.included.usdMicros; + const totalUsdMicros = included?.total.usdMicros ?? remainingUsdMicros; + const consumedUsdMicros = included?.consumed.usdMicros ?? "0"; + if (parseUsdMicros(totalUsdMicros) <= BigInt(0)) return null; + + const planName = included?.sourcePlan?.name?.trim() || null; + const planId = included?.sourcePlan?.id?.trim() || null; + const resetsAt = included?.resetsAt?.trim() || null; + + return { + remainingUsdMicros, + totalUsdMicros, + consumedUsdMicros, + remainingUsd: formatWalletUsd(remainingUsdMicros), + totalUsd: formatWalletUsd(totalUsdMicros), + consumedUsd: formatWalletUsd(consumedUsdMicros), + planId, + planName, + resetsAt, + }; } export function includedUsageRemainingLabel( - _included: IncludedUsageSummary | null -): string | null { - return null; + summary: IncludedUsageSummary, +): string { + const plan = summary.planName ?? "Plan"; + return `${plan} · $${summary.remainingUsd} of $${summary.totalUsd} included left`; +} + +/** When the next invoice goes out, in the customer's terms. */ +export function collectionSchedule(state: BillingState): string { + const lead = state.collection.leadThreshold; + if (lead.usdMicros === "0") { + return `Usage is invoiced every ${state.collection.collectionInterval.toLowerCase()}.`; + } + return ( + `Usage is invoiced automatically once $${lead.usd} of it has built up, ` + + `and at least once a ${state.collection.collectionInterval.toLowerCase()}.` + ); }