Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
88bbd11
feat: add pymthouse plans list + subscribe checkout on Usage
eliteprox Aug 6, 2026
98a446e
chore(deps): pin @pymthouse/builder-sdk to published 0.6.2
eliteprox Aug 6, 2026
a7faf61
merge: sync with pymthouse-integration and pin SDK 0.6.3
eliteprox Aug 6, 2026
6ec9fc5
Wire dashboard payment methods and Connect invoices
eliteprox Aug 7, 2026
f09522d
Add billing payment-method row actions and Connect invoice copy.
eliteprox Aug 7, 2026
19deb57
feat(billing): route subscriptions by live state
eliteprox Aug 7, 2026
bbb8385
fix(usage): validate account-usage payloads instead of casting them
eliteprox Aug 8, 2026
c4a5c1f
refactor(keys): resolve the signer URL from issuer routing, not dashb…
eliteprox Aug 8, 2026
59b954a
fix(billing): gate cancel affordances on the live plan, add owner wallet
eliteprox Aug 8, 2026
f810872
feat(billing): drive wallet UI from billingState spend posture
eliteprox Aug 9, 2026
bc55042
refactor(auth): update user ID handling and async methods in AuthContext
eliteprox Aug 11, 2026
6d4b24a
feat(billing): enhance billing plan handling and UI updates
eliteprox Aug 11, 2026
27e2718
feat(subscriptions): implement user subscription retrieval API and en…
eliteprox Aug 11, 2026
3189cd1
chore(dependencies): update @pymthouse/builder-sdk to version 0.6.4
eliteprox Aug 11, 2026
777fdf7
feat(wallet): enhance available runway calculation and add spendable …
eliteprox Aug 19, 2026
59ac765
feat(wallet): integrate wallet billing state and enhance usage display
eliteprox Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,18 @@ 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
# Public app client id (OAuth client_id) — used for Builder API path {clientId}
# Local pymthouse (`npm run dev` HTTPS): https://localhost:3001/api/v1/oidc
# (mkcert is trusted in the browser; Node fetch to localhost HTTPS usually works
# without PYMTHOUSE_ALLOW_INSECURE_HTTP when using the system/mkcert CA.)
PYMTHOUSE_PUBLIC_CLIENT_ID=
PYMTHOUSE_M2M_CLIENT_ID=
PYMTHOUSE_M2M_CLIENT_SECRET=
# Remote signer DMZ base included as signer_url in API-key exchange responses
# (fallback when the issuer does not return signer_url).
# Production: https://pymthouse-production.up.railway.app
# Local compose: http://127.0.0.1:8080
PYMTHOUSE_SIGNER_URL=https://pymthouse-production.up.railway.app
# Set to 1 for local dev when issuer uses http://127.0.0.1
# Signer DMZ URL is returned by issuer exchange / GET …/apps/{id}/signer/routing
# — do not set PYMTHOUSE_SIGNER_URL on the dashboard.
# Set to 1 for local http issuer only (not needed for https://localhost with mkcert)
PYMTHOUSE_ALLOW_INSECURE_HTTP=

# Live-runner discovery (orchestrator /discovery endpoint).
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/usage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function UsageContent() {
<DashboardPageHeader
title="Usage"
icon={BarChart3}
description="Signed requests, network cost, and Starter allowance from PymtHouse OpenMeter."
description="Signed requests, network cost, and prepaid balance usage from PymtHouse OpenMeter."
actions={
<>
<button
Expand Down
46 changes: 46 additions & 0 deletions app/api/pymthouse/invoices/[invoiceId]/hosted-url/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
40 changes: 40 additions & 0 deletions app/api/pymthouse/invoices/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
11 changes: 2 additions & 9 deletions app/api/pymthouse/keys/exchange/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ type ExchangeConfig = {
publicClientId: string;
m2mClientId: string;
m2mClientSecret: string;
signerUrl: string | undefined;
};

/** Thin BFF; canonical issuer route is POST …/apps/{clientId}/oidc/token (RFC 8693). */
Expand All @@ -24,17 +23,11 @@ function readApiKeyExchangeConfig(): ExchangeConfig | null {
if (!issuerUrl || !publicClientId) {
return null;
}
const signerUrl =
process.env.PYMTHOUSE_CLIENT_SIGNER_API_URL?.trim() ||
process.env.PYMTHOUSE_SIGNER_URL?.trim() ||
process.env.SIGNER_PUBLIC_URL?.trim() ||
undefined;
return {
issuerUrl,
publicClientId,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim() ?? "",
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim() ?? "",
signerUrl,
};
}

Expand Down Expand Up @@ -114,8 +107,8 @@ async function exchangeApiKeyViaOidcToken(input: {
});
}

const signerUrl =
readStringField(parsed, "signer_url") || config.signerUrl || undefined;
// signer_url comes from the issuer exchange response (app signer routing).
const signerUrl = readStringField(parsed, "signer_url");

const expiresIn =
typeof parsed.expires_in === "number" && Number.isFinite(parsed.expires_in)
Expand Down
215 changes: 215 additions & 0 deletions app/api/pymthouse/payment-methods/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from "next/server";
import { PmtHouseError } from "@pymthouse/builder-sdk";
import {
listDashboardUserPaymentMethods,
removeDashboardUserPaymentMethod,
ensureDashboardUserDefaultPaymentMethod,
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) {
let body: {
externalUserId?: string;
paymentMethodId?: string;
ensureDefault?: boolean;
};
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 },
);
}

if (body.ensureDefault === true) {
try {
return NextResponse.json(
await ensureDashboardUserDefaultPaymentMethod(externalUserId),
);
} catch (error) {
return billingErrorResponse(error, "Failed to ensure default payment method");
}
}

const paymentMethodId = body.paymentMethodId?.trim();
if (!paymentMethodId) {
return NextResponse.json(
{ error: "paymentMethodId is required" },
{ status: 400 },
);
}

try {
return NextResponse.json(
await setDashboardUserDefaultPaymentMethod(
externalUserId,
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 },
);
}
Loading