From 88bbd119be917909e94acc41c9cbb82588330b16 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 5 Aug 2026 22:30:33 -0400 Subject: [PATCH 01/13] feat: add pymthouse plans list + subscribe checkout on Usage Wire BFF routes and PlansPanel through builder-sdk createBillingCheckout so dashboard can exercise end-user checkout against pymthouse before NaaP. --- .env.example | 2 +- app/api/pymthouse/plans/route.ts | 22 +++++ app/api/pymthouse/subscribe/route.ts | 54 ++++++++++++ components/dashboard/PlansPanel.tsx | 114 +++++++++++++++++++++++++ components/dashboard/UsageView.tsx | 3 + lib/dashboard/pymthouse-billing-bff.ts | 72 ++++++++++++++++ lib/dashboard/useBillingPlans.ts | 76 +++++++++++++++++ package.json | 7 +- pnpm-lock.yaml | 11 +-- 9 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 app/api/pymthouse/plans/route.ts create mode 100644 app/api/pymthouse/subscribe/route.ts create mode 100644 components/dashboard/PlansPanel.tsx create mode 100644 lib/dashboard/pymthouse-billing-bff.ts create mode 100644 lib/dashboard/useBillingPlans.ts diff --git a/.env.example b/.env.example index 920b0b8..83bf232 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ THEGRAPH_API_KEY= # Aliases: DISCOVERY_URL, LIVEPEER_DISCOVERY_SERVICE_URL DISCOVERY_SERVICE_URL=https://discovery-service-production-8955.up.railway.app/v1/discovery/raw -# PymtHouse (usage + API keys + signer sessions) +# PymtHouse (usage + API keys + signer sessions + plans/subscribe) # Production: PYMTHOUSE_ISSUER_URL=https://pymthouse.com/api/v1/oidc # Local pymthouse: http://localhost:3001/api/v1/oidc diff --git a/app/api/pymthouse/plans/route.ts b/app/api/pymthouse/plans/route.ts new file mode 100644 index 0000000..dcfd803 --- /dev/null +++ b/app/api/pymthouse/plans/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { listDashboardBillingPlans } from "@/lib/dashboard/pymthouse-billing-bff"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const plans = await listDashboardBillingPlans(); + return NextResponse.json({ plans }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = + error instanceof Error ? error.message : "Failed to list billing plans"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/pymthouse/subscribe/route.ts b/app/api/pymthouse/subscribe/route.ts new file mode 100644 index 0000000..2eacea1 --- /dev/null +++ b/app/api/pymthouse/subscribe/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { startDashboardBillingCheckout } from "@/lib/dashboard/pymthouse-billing-bff"; + +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + let body: { + planId?: string; + externalUserId?: string; + successUrl?: string; + cancelUrl?: string; + }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const planId = body.planId?.trim(); + const externalUserId = body.externalUserId?.trim(); + if (!planId || !externalUserId) { + return NextResponse.json( + { error: "planId and externalUserId are required" }, + { status: 400 }, + ); + } + + const origin = request.nextUrl.origin; + const successUrl = + body.successUrl?.trim() || `${origin}/usage?checkout=success`; + const cancelUrl = + body.cancelUrl?.trim() || `${origin}/usage?checkout=cancel`; + + try { + const result = await startDashboardBillingCheckout({ + planId, + externalUserId, + successUrl, + cancelUrl, + }); + return NextResponse.json(result); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = + error instanceof Error ? error.message : "Failed to start checkout"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/components/dashboard/PlansPanel.tsx b/components/dashboard/PlansPanel.tsx new file mode 100644 index 0000000..6834997 --- /dev/null +++ b/components/dashboard/PlansPanel.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import Button from "@/components/design-system/Button"; +import { useBillingPlans } from "@/lib/dashboard/useBillingPlans"; + +function formatPrice(amount: string, currency: string, cycle: string | null): string { + const n = Number(amount); + const money = Number.isFinite(n) + ? new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency || "USD", + }).format(n) + : amount; + if (!cycle) return money; + const c = cycle.toLowerCase(); + if (c === "monthly" || c === "month") return `${money}/mo`; + if (c === "yearly" || c === "year" || c === "annual") return `${money}/yr`; + return `${money} · ${cycle}`; +} + +export default function PlansPanel({ + externalUserId, +}: { + externalUserId: string | undefined; +}) { + const { state, reload, subscribe } = useBillingPlans(); + const [busyPlanId, setBusyPlanId] = useState(null); + const [error, setError] = useState(null); + + async function onSubscribe(planId: string) { + if (!externalUserId?.trim()) { + setError("Sign in to subscribe."); + return; + } + setError(null); + setBusyPlanId(planId); + try { + const { checkoutUrl } = await subscribe({ + planId, + externalUserId: externalUserId.trim(), + }); + window.location.assign(checkoutUrl); + } catch (err) { + setError(err instanceof Error ? err.message : "Checkout failed"); + setBusyPlanId(null); + } + } + + if (state.status === "loading" || state.status === "idle") { + return ( +
+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+

Could not load plans.

+

{state.message}

+ +
+ ); + } + + if (state.plans.length === 0) { + return null; + } + + return ( +
+
+

Plans

+

+ Subscribe via PymtHouse → Stripe Checkout +

+
+
    + {state.plans.map((plan) => ( +
  • +
    +

    {plan.name || plan.id}

    +

    + {formatPrice(plan.priceAmount, plan.priceCurrency, plan.billingCycle)} + {plan.capabilityCount > 0 + ? ` · ${plan.capabilityCount} capabilities` + : ""} +

    +
    + +
  • + ))} +
+ {error ? ( +

{error}

+ ) : null} +
+ ); +} diff --git a/components/dashboard/UsageView.tsx b/components/dashboard/UsageView.tsx index 182a04d..aab3b9c 100644 --- a/components/dashboard/UsageView.tsx +++ b/components/dashboard/UsageView.tsx @@ -14,6 +14,7 @@ import { type UsageCapabilityRow, } from "@/lib/dashboard/usage-capability-display"; import DashboardPageSkeleton from "@/components/dashboard/DashboardPageSkeleton"; +import PlansPanel from "@/components/dashboard/PlansPanel"; const PERIOD_DAYS = 30; @@ -288,6 +289,8 @@ export default function UsageView() { resetsAt={resetsAt} /> + +
diff --git a/lib/dashboard/pymthouse-billing-bff.ts b/lib/dashboard/pymthouse-billing-bff.ts new file mode 100644 index 0000000..fa4ae87 --- /dev/null +++ b/lib/dashboard/pymthouse-billing-bff.ts @@ -0,0 +1,72 @@ +import { + PmtHouseError, + type BillingProduct, + type CreateBillingCheckoutResult, +} from "@pymthouse/builder-sdk"; +import { createPmtHouseClientForPublicApp } from "@/lib/dashboard/pymthouse-bff"; + +function readPublicClientId(): string { + const id = + process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() || + process.env.DASHBOARD_DEVICE_PUBLIC_CLIENT_ID?.trim(); + if (!id) { + throw new PmtHouseError( + "PYMTHOUSE_PUBLIC_CLIENT_ID (or DASHBOARD_DEVICE_PUBLIC_CLIENT_ID) is required", + { status: 503, code: "pymthouse_required" }, + ); + } + return id; +} + +export type DashboardBillingPlan = { + id: string; + name: string; + type: string; + status: string; + priceAmount: string; + priceCurrency: string; + billingCycle: string | null; + capabilityCount: number; +}; + +function mapProduct(product: BillingProduct): DashboardBillingPlan { + return { + id: product.id, + name: product.name, + type: product.type, + status: product.status, + priceAmount: product.priceAmount, + priceCurrency: product.priceCurrency, + billingCycle: product.allowance?.billingCycle ?? null, + capabilityCount: product.capabilities?.length ?? 0, + }; +} + +/** Active (non-starter / non-network-default) products available for subscribe. */ +export async function listDashboardBillingPlans(): Promise { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + const { products } = await client.listBillingProducts(); + return (products ?? []) + .filter( + (p) => + p.status === "active" && + !p.isNetworkDefault && + !p.isStarterDefault, + ) + .map(mapProduct); +} + +export async function startDashboardBillingCheckout(input: { + planId: string; + externalUserId: string; + successUrl?: string; + cancelUrl?: string; +}): Promise { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.createBillingCheckout({ + planId: input.planId, + externalUserId: input.externalUserId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + }); +} diff --git a/lib/dashboard/useBillingPlans.ts b/lib/dashboard/useBillingPlans.ts new file mode 100644 index 0000000..37c1b62 --- /dev/null +++ b/lib/dashboard/useBillingPlans.ts @@ -0,0 +1,76 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { DashboardBillingPlan } from "@/lib/dashboard/pymthouse-billing-bff"; + +export type BillingPlansState = + | { status: "idle" } + | { status: "loading" } + | { status: "ready"; plans: DashboardBillingPlan[] } + | { status: "error"; message: string }; + +async function readResponseJson(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + throw new Error(`Empty response (${response.status})`); + } + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`Invalid JSON (${response.status})`); + } +} + +export function useBillingPlans() { + const [state, setState] = useState({ status: "idle" }); + + const load = useCallback(async () => { + setState({ status: "loading" }); + try { + const response = await fetch("/api/pymthouse/plans"); + const body = await readResponseJson<{ + plans?: DashboardBillingPlan[]; + error?: string; + }>(response); + if (!response.ok) { + throw new Error(body.error ?? `Plans fetch failed (${response.status})`); + } + setState({ status: "ready", plans: body.plans ?? [] }); + } catch (error) { + setState({ + status: "error", + message: + error instanceof Error ? error.message : "Failed to load plans", + }); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const subscribe = useCallback( + async (input: { planId: string; externalUserId: string }) => { + const response = await fetch("/api/pymthouse/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const body = await readResponseJson<{ + checkoutUrl?: string; + subscriptionId?: string; + error?: string; + }>(response); + if (!response.ok || !body.checkoutUrl) { + throw new Error(body.error ?? `Subscribe failed (${response.status})`); + } + return { + checkoutUrl: body.checkoutUrl, + subscriptionId: body.subscriptionId, + }; + }, + [], + ); + + return { state, reload: load, subscribe }; +} diff --git a/package.json b/package.json index 259bc7a..fff5293 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "format:check": "prettier . --check" }, "dependencies": { - "@pymthouse/builder-sdk": "0.6.1", + "@pymthouse/builder-sdk": "github:pymthouse/builder-sdk#feat/create-billing-checkout", "framer-motion": "^11.15.0", "geist": "^1.7.0", "jmuxer": "^2.1.0", @@ -39,5 +39,10 @@ "prettier": "^3.8.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@pymthouse/builder-sdk" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 579e677..c14d885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@pymthouse/builder-sdk': - specifier: 0.6.1 - version: 0.6.1 + specifier: github:pymthouse/builder-sdk#feat/create-billing-checkout + version: https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605 framer-motion: specifier: ^11.15.0 version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -401,8 +401,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@pymthouse/builder-sdk@0.6.1': - resolution: {integrity: sha512-ALGluRpNAdnoK0Qk1ugP7TVcTrEPtnkipeIsSx6UX6hD1MN8mtOrgZAi+dhB2VZGEM7OBdWz1YhK6V1yskialw==} + '@pymthouse/builder-sdk@https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605': + resolution: {tarball: https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605} + version: 0.6.2 engines: {node: '>=20'} '@reduxjs/toolkit@2.11.2': @@ -2331,7 +2332,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@pymthouse/builder-sdk@0.6.1': + '@pymthouse/builder-sdk@https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605': dependencies: oauth4webapi: 3.8.6 From 98a446e5f6791297e1cc27ffa75c85e6c5144c17 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Wed, 5 Aug 2026 22:46:53 -0400 Subject: [PATCH 02/13] chore(deps): pin @pymthouse/builder-sdk to published 0.6.2 builder-sdk#50 merged and published; drop the git branch pin and the onlyBuiltDependencies workaround needed for prepare-on-install. --- package.json | 7 +------ pnpm-lock.yaml | 11 +++++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index fff5293..54d8986 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "format:check": "prettier . --check" }, "dependencies": { - "@pymthouse/builder-sdk": "github:pymthouse/builder-sdk#feat/create-billing-checkout", + "@pymthouse/builder-sdk": "0.6.2", "framer-motion": "^11.15.0", "geist": "^1.7.0", "jmuxer": "^2.1.0", @@ -39,10 +39,5 @@ "prettier": "^3.8.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "@pymthouse/builder-sdk" - ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c14d885..5596f9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@pymthouse/builder-sdk': - specifier: github:pymthouse/builder-sdk#feat/create-billing-checkout - version: https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605 + specifier: 0.6.2 + version: 0.6.2 framer-motion: specifier: ^11.15.0 version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -401,9 +401,8 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@pymthouse/builder-sdk@https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605': - resolution: {tarball: https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605} - version: 0.6.2 + '@pymthouse/builder-sdk@0.6.2': + resolution: {integrity: sha512-IXxgOyAqRcnkU+TipBFMpEeL0epMzUCrDWt8jwsftZpnc60y3ZZlPrxb0IbP+DxKIan9HXrJtQJsfz00yV7E9w==} engines: {node: '>=20'} '@reduxjs/toolkit@2.11.2': @@ -2332,7 +2331,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@pymthouse/builder-sdk@https://codeload.github.com/pymthouse/builder-sdk/tar.gz/08f3f4afdf90aa6d641a26b4f93b2fd8c0ced605': + '@pymthouse/builder-sdk@0.6.2': dependencies: oauth4webapi: 3.8.6 From 6ec9fc571d45116fc1455c20eaa3f2e5f210d174 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 6 Aug 2026 22:10:20 -0400 Subject: [PATCH 03/13] Wire dashboard payment methods and Connect invoices Add merchant card controls and invoice links through the billing BFF so users can manage their payment method and access Connect-hosted invoices. --- .../invoices/[invoiceId]/hosted-url/route.ts | 46 +++++ app/api/pymthouse/invoices/route.ts | 40 ++++ app/api/pymthouse/payment-methods/route.ts | 181 ++++++++++++++++++ lib/dashboard/pymthouse-billing-bff.ts | 88 +++++++++ lib/dashboard/useBillingAccount.ts | 179 +++++++++++++++++ package.json | 3 +- pnpm-lock.yaml | 10 +- 7 files changed, 541 insertions(+), 6 deletions(-) create mode 100644 app/api/pymthouse/invoices/[invoiceId]/hosted-url/route.ts create mode 100644 app/api/pymthouse/invoices/route.ts create mode 100644 app/api/pymthouse/payment-methods/route.ts create mode 100644 lib/dashboard/useBillingAccount.ts 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..6b9f568 --- /dev/null +++ b/app/api/pymthouse/invoices/[invoiceId]/hosted-url/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { getDashboardUserInvoiceHostedUrl } from "@/lib/dashboard/pymthouse-billing-bff"; + +export const runtime = "nodejs"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ invoiceId: string }> }, +) { + const externalUserId = + request.nextUrl.searchParams.get("externalUserId")?.trim() || ""; + const { invoiceId: rawInvoiceId } = await params; + const invoiceId = decodeURIComponent(rawInvoiceId).trim(); + + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400 }, + ); + } + if (!invoiceId) { + return NextResponse.json( + { error: "invoiceId is required" }, + { status: 400 }, + ); + } + + try { + const links = await getDashboardUserInvoiceHostedUrl( + externalUserId, + invoiceId, + ); + return NextResponse.json(links); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = + error instanceof Error ? error.message : "Failed to resolve invoice link"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/pymthouse/invoices/route.ts b/app/api/pymthouse/invoices/route.ts new file mode 100644 index 0000000..683dda7 --- /dev/null +++ b/app/api/pymthouse/invoices/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { listDashboardUserInvoices } from "@/lib/dashboard/pymthouse-billing-bff"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const externalUserId = + request.nextUrl.searchParams.get("externalUserId")?.trim() || ""; + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400 }, + ); + } + + const pageRaw = Number(request.nextUrl.searchParams.get("page") || "1"); + const pageSizeRaw = Number( + request.nextUrl.searchParams.get("pageSize") || "20", + ); + + try { + const result = await listDashboardUserInvoices(externalUserId, { + page: Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1, + pageSize: + Number.isFinite(pageSizeRaw) && pageSizeRaw > 0 ? pageSizeRaw : 20, + }); + return NextResponse.json(result); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = + error instanceof Error ? error.message : "Failed to load invoices"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/pymthouse/payment-methods/route.ts b/app/api/pymthouse/payment-methods/route.ts new file mode 100644 index 0000000..dfd9663 --- /dev/null +++ b/app/api/pymthouse/payment-methods/route.ts @@ -0,0 +1,181 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { + listDashboardUserPaymentMethods, + removeDashboardUserPaymentMethod, + setDashboardUserDefaultPaymentMethod, + startDashboardPaymentMethodCheckout, +} from "@/lib/dashboard/pymthouse-billing-bff"; + +export const runtime = "nodejs"; + +function checkoutReturnOrigin(request: NextRequest): string { + const configuredOrigin = ( + process.env.DASHBOARD_PUBLIC_URL || + process.env.NEXT_PUBLIC_APP_URL || + "" + ) + .trim() + .replace(/\/$/, ""); + let origin = configuredOrigin || request.nextUrl.origin; + try { + const parsed = new URL(origin); + if ( + parsed.protocol === "http:" && + parsed.hostname !== "localhost" && + parsed.hostname !== "127.0.0.1" + ) { + parsed.protocol = "https:"; + origin = parsed.origin; + } else { + origin = parsed.origin; + } + } catch { + origin = request.nextUrl.origin; + } + return origin; +} + +export async function GET(request: NextRequest) { + const externalUserId = + request.nextUrl.searchParams.get("externalUserId")?.trim() || ""; + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400 }, + ); + } + + try { + const paymentMethods = + await listDashboardUserPaymentMethods(externalUserId); + return NextResponse.json({ paymentMethods }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = + error instanceof Error ? error.message : "Failed to load payment methods"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +export async function POST(request: NextRequest) { + let body: { + externalUserId?: string; + successUrl?: string; + cancelUrl?: string; + }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const externalUserId = body.externalUserId?.trim(); + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { 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 result = await startDashboardPaymentMethodCheckout({ + externalUserId, + successUrl, + cancelUrl, + }); + return NextResponse.json(result); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = + error instanceof Error + ? error.message + : "Failed to start payment method checkout"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +async function readPaymentMethodMutation(request: NextRequest): Promise< + | { externalUserId: string; paymentMethodId: string } + | NextResponse +> { + let body: { externalUserId?: string; paymentMethodId?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + const externalUserId = body.externalUserId?.trim(); + const paymentMethodId = body.paymentMethodId?.trim(); + if (!externalUserId || !paymentMethodId) { + return NextResponse.json( + { error: "externalUserId and paymentMethodId are required" }, + { status: 400 }, + ); + } + return { externalUserId, paymentMethodId }; +} + +export async function PATCH(request: NextRequest) { + const input = await readPaymentMethodMutation(request); + if (input instanceof NextResponse) { + return input; + } + try { + return NextResponse.json( + await setDashboardUserDefaultPaymentMethod( + input.externalUserId, + input.paymentMethodId, + ), + ); + } catch (error) { + return billingErrorResponse(error, "Failed to set default payment method"); + } +} + +export async function DELETE(request: NextRequest) { + const input = await readPaymentMethodMutation(request); + if (input instanceof NextResponse) { + return input; + } + try { + return NextResponse.json( + await removeDashboardUserPaymentMethod( + input.externalUserId, + input.paymentMethodId, + ), + ); + } catch (error) { + return billingErrorResponse(error, "Failed to remove payment method"); + } +} + +function billingErrorResponse(error: unknown, fallback: string): NextResponse { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + return NextResponse.json( + { error: error instanceof Error ? error.message : fallback }, + { status: 502 }, + ); +} diff --git a/lib/dashboard/pymthouse-billing-bff.ts b/lib/dashboard/pymthouse-billing-bff.ts index fa4ae87..41a06b1 100644 --- a/lib/dashboard/pymthouse-billing-bff.ts +++ b/lib/dashboard/pymthouse-billing-bff.ts @@ -1,7 +1,12 @@ import { PmtHouseError, + type AppUserInvoice, + type AppUserInvoiceHostedUrlResult, + type AppUserPaymentMethod, type BillingProduct, + type CreateAppUserPaymentMethodCheckoutResult, type CreateBillingCheckoutResult, + type UserSubscriptionResponse, } from "@pymthouse/builder-sdk"; import { createPmtHouseClientForPublicApp } from "@/lib/dashboard/pymthouse-bff"; @@ -70,3 +75,86 @@ export async function startDashboardBillingCheckout(input: { ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), }); } + +export type DashboardUserSubscription = { + planId: string | null; + planName: string | null; + status: string | null; + subscriptionId: string | null; +}; + +export async function getDashboardUserSubscription( + externalUserId: string, +): Promise { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + const result: UserSubscriptionResponse = + await client.getUserSubscription(externalUserId); + const sub = result.subscription; + return { + planId: sub?.planId?.trim() || null, + planName: sub?.planName?.trim() || null, + status: sub?.status?.trim() || null, + subscriptionId: sub?.id?.trim() || null, + }; +} + +export type DashboardInvoice = AppUserInvoice; +export type DashboardPaymentMethod = AppUserPaymentMethod; + +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); +} + +export async function removeDashboardUserPaymentMethod( + externalUserId: string, + paymentMethodId: string, +) { + const client = createPmtHouseClientForPublicApp(readPublicClientId()); + return client.unlinkUserPaymentMethod(externalUserId, paymentMethodId); +} diff --git a/lib/dashboard/useBillingAccount.ts b/lib/dashboard/useBillingAccount.ts new file mode 100644 index 0000000..0fc47da --- /dev/null +++ b/lib/dashboard/useBillingAccount.ts @@ -0,0 +1,179 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { + DashboardInvoice, + DashboardPaymentMethod, +} from "@/lib/dashboard/pymthouse-billing-bff"; + +async function readResponseJson(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + throw new Error(`Empty response (${response.status})`); + } + try { + return JSON.parse(text) as T; + } catch { + throw new Error(`Invalid JSON (${response.status})`); + } +} + +export type BillingAccountState = + | { status: "idle" } + | { status: "loading" } + | { + status: "ready"; + paymentMethods: DashboardPaymentMethod[]; + invoices: DashboardInvoice[]; + } + | { status: "error"; message: string }; + +export function useBillingAccount(externalUserId: string | undefined) { + const [state, setState] = useState({ status: "idle" }); + + const load = useCallback(async () => { + const trimmed = externalUserId?.trim(); + if (!trimmed) { + setState({ + status: "ready", + paymentMethods: [], + invoices: [], + }); + return; + } + + setState({ status: "loading" }); + try { + const q = encodeURIComponent(trimmed); + const [pmResponse, invResponse] = await Promise.all([ + fetch(`/api/pymthouse/payment-methods?externalUserId=${q}`), + fetch(`/api/pymthouse/invoices?externalUserId=${q}&pageSize=20`), + ]); + + const pmBody = await readResponseJson<{ + paymentMethods?: DashboardPaymentMethod[]; + error?: string; + }>(pmResponse); + if (!pmResponse.ok) { + throw new Error( + pmBody.error ?? `Payment methods failed (${pmResponse.status})`, + ); + } + + const invBody = await readResponseJson<{ + items?: DashboardInvoice[]; + error?: string; + }>(invResponse); + if (!invResponse.ok) { + throw new Error( + invBody.error ?? `Invoices failed (${invResponse.status})`, + ); + } + + setState({ + status: "ready", + paymentMethods: pmBody.paymentMethods ?? [], + invoices: invBody.items ?? [], + }); + } catch (error) { + setState({ + status: "error", + message: + error instanceof Error ? error.message : "Failed to load billing", + }); + } + }, [externalUserId]); + + useEffect(() => { + void load(); + }, [load]); + + const startPaymentMethodCheckout = useCallback( + async (input: { externalUserId: string }) => { + const response = await fetch("/api/pymthouse/payment-methods", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ externalUserId: input.externalUserId }), + }); + 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: { externalUserId: string; invoiceId: string }) => { + const response = await fetch( + `/api/pymthouse/invoices/${encodeURIComponent(input.invoiceId)}/hosted-url?externalUserId=${encodeURIComponent(input.externalUserId)}`, + ); + const body = await readResponseJson<{ + hostedInvoiceUrl?: string | null; + invoicePdf?: string | null; + 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, + }; + }, + [], + ); + + const setDefaultPaymentMethod = useCallback( + async (input: { externalUserId: string; paymentMethodId: string }) => { + const response = await fetch("/api/pymthouse/payment-methods", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + 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 removePaymentMethod = useCallback( + async (input: { externalUserId: string; paymentMethodId: string }) => { + const response = await fetch("/api/pymthouse/payment-methods", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + 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, + removePaymentMethod, + }; +} diff --git a/package.json b/package.json index 75fe678..f9bf759 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "packageManager": "pnpm@10.33.0", "scripts": { "dev": "next dev", + "dev:https": "next dev --experimental-https", "build": "next build", "start": "next start", "lint": "eslint . --max-warnings 0", @@ -13,7 +14,7 @@ "format:check": "prettier . --check" }, "dependencies": { - "@pymthouse/builder-sdk": "0.6.3", + "@pymthouse/builder-sdk": "file:../builder-sdk", "framer-motion": "^11.15.0", "geist": "^1.7.0", "jmuxer": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c3d2cd..f92ba5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@pymthouse/builder-sdk': - specifier: 0.6.3 - version: 0.6.3 + specifier: file:../builder-sdk + version: file:../builder-sdk framer-motion: specifier: ^11.15.0 version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -401,8 +401,8 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@pymthouse/builder-sdk@0.6.3': - resolution: {integrity: sha512-ex+qcGknv6gs+FqvmgoQgR5Trwy/yQ7UIO91jlGVPpgwSccFqTunP/Lmi+ABY9Gz1F0xN+U9C6AYOhkFSb/Nsw==} + '@pymthouse/builder-sdk@file:../builder-sdk': + resolution: {directory: ../builder-sdk, type: directory} engines: {node: '>=20'} '@reduxjs/toolkit@2.11.2': @@ -2331,7 +2331,7 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@pymthouse/builder-sdk@0.6.3': + '@pymthouse/builder-sdk@file:../builder-sdk': dependencies: oauth4webapi: 3.8.6 From f09522d53c9f944b258b30084940360fcf0bfc68 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 6 Aug 2026 22:15:49 -0400 Subject: [PATCH 04/13] Add billing payment-method row actions and Connect invoice copy. Wire useBillingAccount into BillingSection: live payment method list with Set-as-default and Remove (confirm) row actions, live invoice table with View/PDF links resolved via the Stripe hosted-url endpoint, and updated copy ("Stripe invoices for this account"). Removes static blur/WIP overlay and fake billing-details block; plan section stays static pending subscribe-flow work. --- .../dashboard/settings/BillingSection.tsx | 428 +++++++++++------- 1 file changed, 275 insertions(+), 153 deletions(-) diff --git a/components/dashboard/settings/BillingSection.tsx b/components/dashboard/settings/BillingSection.tsx index d46eb20..265e271 100644 --- a/components/dashboard/settings/BillingSection.tsx +++ b/components/dashboard/settings/BillingSection.tsx @@ -1,36 +1,140 @@ "use client"; -import { ArrowRight, Box, Check, Download, Plus } from "lucide-react"; +import { useState } from "react"; +import { ArrowRight, Box, Check, CreditCard, Download, Plus, Trash2 } from "lucide-react"; +import { useAuth } from "@/components/dashboard/AuthContext"; import { IconButton, SettingsCard, - SettingsField, SettingsHeader, - SettingsInput, - SettingsTextarea, ST_COLS_5, ST_HEAD_CLASS, } from "./SettingsPrimitives"; +import { useBillingAccount } from "@/lib/dashboard/useBillingAccount"; + +function formatInvoiceAmount(totalAmount: string, currency: string): string { + const n = Number(totalAmount); + if (!Number.isFinite(n)) return `${totalAmount} ${currency}`; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency || "USD", + }).format(n); +} + +function formatInvoiceDate(iso: string | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} /** * Organization · Billing — `?tab=billing` per the v7 prototype. * - * Four blocks: - * 1. Plan — three plan cards side by side (Free, Pro, Scale) - * 2. Payment method — empty state ("No payment method · Add a card…") - * 3. Billing details — company / email / tax ID / address fields - * 4. Invoices — table of historical invoices + * Plan block is static (subscribe flow is a separate WIP). + * Payment method and invoices are live, wired to useBillingAccount. */ export default function BillingSection() { - // Billing view is rendered behind a blur with a "Work in progress" notice - // on top — reviewers can see the surface area without mistaking it for a - // finalized flow. Real treatment is still being designed. + const { user } = useAuth(); + const externalUserId = user?.id?.trim(); + + const { + state: accountState, + reload: reloadAccount, + startPaymentMethodCheckout, + openInvoice, + setDefaultPaymentMethod, + removePaymentMethod, + } = useBillingAccount(externalUserId); + + const [pmBusy, setPmBusy] = useState(false); + const [paymentMethodActionId, setPaymentMethodActionId] = useState(null); + const [invoiceBusyId, setInvoiceBusyId] = useState(null); + const [error, setError] = useState(null); + + async function onAddCard() { + if (!externalUserId) { + setError("Sign in to add a payment method."); + return; + } + setError(null); + setPmBusy(true); + try { + const { checkoutUrl } = await startPaymentMethodCheckout({ externalUserId }); + window.location.assign(checkoutUrl); + } catch (err) { + setError(err instanceof Error ? err.message : "Payment method checkout failed"); + setPmBusy(false); + } + } + + async function onOpenInvoice(invoiceId: string, prefer: "hosted" | "pdf") { + if (!externalUserId) return; + setError(null); + setInvoiceBusyId(invoiceId); + try { + const links = await openInvoice({ externalUserId, invoiceId }); + const url = + prefer === "pdf" + ? links.invoicePdf || links.hostedInvoiceUrl + : links.hostedInvoiceUrl || links.invoicePdf; + if (!url) { + throw new Error("No Stripe invoice page for this invoice yet."); + } + window.open(url, "_blank", "noopener,noreferrer"); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not open invoice"); + } finally { + setInvoiceBusyId(null); + } + } + + async function onSetDefaultPaymentMethod(paymentMethodId: string) { + if (!externalUserId) return; + setError(null); + setPaymentMethodActionId(paymentMethodId); + try { + await setDefaultPaymentMethod({ externalUserId, paymentMethodId }); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not set default payment method"); + } finally { + setPaymentMethodActionId(null); + } + } + + async function onRemovePaymentMethod(paymentMethodId: string) { + if (!externalUserId) return; + if (!window.confirm("Remove this payment method?")) return; + setError(null); + setPaymentMethodActionId(paymentMethodId); + try { + await removePaymentMethod({ externalUserId, paymentMethodId }); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not remove payment method"); + } finally { + setPaymentMethodActionId(null); + } + } + + const accountLoading = + accountState.status === "loading" || accountState.status === "idle"; + const paymentMethods = + accountState.status === "ready" ? accountState.paymentMethods : []; + const invoices = + accountState.status === "ready" ? accountState.invoices : []; + return ( -
-