Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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