fix(demo): align payments-demo with its own hexclave.config.ts catalog (#1651)

## Problem

The demo's `payments-demo` page white-screens on load with:

```
Item with ID "emails_per_month" not found.   (KnownError.fromJson)
```

## Root cause

The demo runs (via the CLI `dev` / development-environment flow) against
an auto-created **Development Environment Project**, which is
provisioned from `examples/demo/hexclave.config.ts`. That config
declares items `api_calls` / `seats` and products `pro` / `team_pro` /
`extra_seats`.

But the demo **code** still referenced the **internal** project's plan
catalog from `@hexclave/shared/plans` — `emails_per_month`, products
`team` / `growth`, `PLAN_LIMITS`, `resolvePlanId`. None of those exist
in the demo's own config, so `team.useItem("emails_per_month")` threw
`ItemNotFound` and crashed the whole page.

Confirmed by dumping the live rendered config
(`tenancy.config.payments`, the exact path the SDK reads) for the
dev-environment project: items `["seats","api_calls"]`, products
`["pro","team_pro","extra_seats"]` — no `emails_per_month`. The
`internal` project's config is healthy and unrelated; this is purely
demo code drift from its own config file.

## Fix

Align the demo code with its own `hexclave.config.ts` (the CLI's
declared source of truth):

- `useItem("seats")` + seat metrics instead of email-quota metrics
- Buy `team_pro` / `extra_seats` instead of `team` / `growth`
- Local `resolveTeamPlan()` instead of `resolvePlanId()` / `PLAN_LIMITS`
- `create-checkout-url` + `config-check` routes updated to the new
catalog
- Copy updates (header, "Free plan" note, manual-end-test instructions)

No backend or provisioning changes — the dev-environment project already
had the correct catalog; only the demo code was stale.

## Verification

- `pnpm --filter @hexclave/example-demo-app typecheck` — pass
- `pnpm --filter @hexclave/example-demo-app lint` — pass
- Demo page loads cleanly: team card renders (active plan "none", 0
seats granted/available), **Buy Team Pro** / **Buy Extra Seat (add-on)**
buttons present, no crash.

## Note

The demo is treated here as a generic SaaS example (its
`hexclave.config.ts` is the curated source of truth). If the intent was
instead for the demo to exercise the internal project's real plans +
email-quota, the alternative fix would be to update `hexclave.config.ts`
rather than the demo code.


<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes the payments demo white-screen by aligning it with its own
`hexclave.config.ts` catalog. The page now loads and uses seat-based
metrics and the correct product IDs.

- **Bug Fixes**
- Use `seats` item and seat metrics; drop
`emails_per_month`/`PLAN_LIMITS`/`resolvePlanId` from
`@hexclave/shared/plans` in favor of a local `resolveTeamPlan()`.
- Switch checkout product IDs to `team_pro` and `extra_seats`; update
API validation, labels, and copy.
- Update `config-check` to expect `api_calls`, `seats`, and
`teamProSeats: 25`.

<sup>Written for commit 8e5bd9a575.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1651?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Transitioned subscription model from email-quota plans to seat-based
Team Pro with Extra Seats add-on
* Updated checkout system and payment configuration for new product
offerings
* Updated demo interface labels and metrics display to reflect
seat-based model

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-06-25 14:47:55 -07:00 committed by GitHub
parent 3e53da8fce
commit 64ddb41374
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 26 additions and 22 deletions

View File

@ -1,5 +1,4 @@
import { branchConfigSchema, getConfigOverrideErrors } from "@hexclave/shared/dist/config/schema";
import { ITEM_IDS, PLAN_LIMITS } from "@hexclave/shared/dist/plans";
import { NextResponse } from "next/server";
import { hexclaveServerApp } from "src/hexclave";
@ -28,9 +27,9 @@ export async function GET() {
expected: {
freePrice: "0.00",
freeInterval: [1, "month"],
freeEmailsPerMonth: PLAN_LIMITS.free.emailsPerMonth,
emailItemId: ITEM_IDS.emailsPerMonth,
emailsPerMonthRepeat: [1, "month"],
apiCallsItemId: "api_calls",
seatsItemId: "seats",
teamProSeats: 25,
},
});
}

View File

@ -5,7 +5,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readBody(value: unknown): { teamId: string, productId: "team" | "growth", returnUrl?: string } {
function readBody(value: unknown): { teamId: string, productId: "team_pro" | "extra_seats", returnUrl?: string } {
if (!isRecord(value)) {
throw new Error("Request body must be an object.");
}
@ -14,8 +14,8 @@ function readBody(value: unknown): { teamId: string, productId: "team" | "growth
if (typeof teamId !== "string" || teamId === "") {
throw new Error("teamId is required.");
}
if (productId !== "team" && productId !== "growth") {
throw new Error("productId must be team or growth.");
if (productId !== "team_pro" && productId !== "extra_seats") {
throw new Error("productId must be team_pro or extra_seats.");
}
if (returnUrl !== undefined && typeof returnUrl !== "string") {
throw new Error("returnUrl must be a string.");

View File

@ -1,7 +1,6 @@
"use client";
import { useStackApp, useUser } from "@hexclave/next";
import { ITEM_IDS, PLAN_LIMITS, resolvePlanId } from "@hexclave/shared/dist/plans";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { Button, Card, CardContent, CardFooter, CardHeader, Input, Typography } from "@hexclave/ui";
import Link from "next/link";
@ -38,7 +37,7 @@ async function readJson(response: Response): Promise<unknown> {
return value;
}
async function createCheckoutUrl(options: { teamId: string, productId: "team" | "growth", returnUrl: string }): Promise<string> {
async function createCheckoutUrl(options: { teamId: string, productId: "team_pro" | "extra_seats", returnUrl: string }): Promise<string> {
const response = await fetch("/payments-demo/api/create-checkout-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -51,10 +50,17 @@ async function createCheckoutUrl(options: { teamId: string, productId: "team" |
return data.url;
}
function resolveTeamPlan(products: ReadonlyArray<{ id: string | null, type?: string }>): string {
const activeSubscriptionIds = new Set(
products.filter((p) => p.type === "subscription" && p.id != null).map((p) => p.id),
);
return activeSubscriptionIds.has("team_pro") ? "team_pro" : "none";
}
function ProductList(props: { team: ReturnType<NonNullable<ReturnType<typeof useUser>>["useTeams"]>[number] }) {
const products = props.team.useProducts();
const emails = props.team.useItem(ITEM_IDS.emailsPerMonth);
const activePlan = resolvePlanId(products);
const seats = props.team.useItem("seats");
const activePlan = resolveTeamPlan(products);
return (
<Card className="overflow-hidden">
@ -73,10 +79,9 @@ function ProductList(props: { team: ReturnType<NonNullable<ReturnType<typeof use
</div>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-3 md:grid-cols-3">
<Metric label="Emails remaining" value={emails.nonNegativeQuantity.toLocaleString()} />
<Metric label="Raw email quantity" value={emails.quantity.toLocaleString()} />
<Metric label="Expected free quota" value={PLAN_LIMITS.free.emailsPerMonth.toLocaleString()} />
<div className="grid gap-3 md:grid-cols-2">
<Metric label="Seats granted" value={seats.quantity.toLocaleString()} />
<Metric label="Seats available" value={seats.nonNegativeQuantity.toLocaleString()} />
</div>
<div className="overflow-auto rounded-md border">
@ -118,8 +123,8 @@ function ProductList(props: { team: ReturnType<NonNullable<ReturnType<typeof use
</div>
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<CheckoutButton team={props.team} productId="team" label="Buy Team" />
<CheckoutButton team={props.team} productId="growth" label="Buy Growth" />
<CheckoutButton team={props.team} productId="team_pro" label="Buy Team Pro" />
<CheckoutButton team={props.team} productId="extra_seats" label="Buy Extra Seat (add-on)" />
</CardFooter>
</Card>
);
@ -136,7 +141,7 @@ function Metric(props: { label: string, value: string }) {
function CheckoutButton(props: {
team: ReturnType<NonNullable<ReturnType<typeof useUser>>["useTeams"]>[number],
productId: "team" | "growth",
productId: "team_pro" | "extra_seats",
label: string,
}) {
const [loading, setLoading] = useState(false);
@ -186,7 +191,7 @@ export default function PaymentsDemoPage() {
await user.setSelectedTeam(team);
setResult({
label: "Created team",
detail: `${team.displayName} (${team.id}). Free plan should appear after the billing grant job/webhook path catches up.`,
detail: `${team.displayName} (${team.id}). The team starts with no products — buy Team Pro below to start a subscription.`,
});
setTeamName(`Payments demo ${new Date().toISOString()}`);
};
@ -224,7 +229,7 @@ export default function PaymentsDemoPage() {
<div>
<Typography type="h1">Payments Demo</Typography>
<Typography className="max-w-3xl text-gray-600 dark:text-gray-400">
Manual test surface for Hexclave internal team plans, Stripe checkout, subscription ending, and email quota deductions.
Manual test surface for the demo team plans (Team Pro + Extra Seats add-on), Stripe checkout, subscription ending, and seat item quantities.
</Typography>
</div>
<Link className="text-sm font-medium underline" href={internalDashboardUrl}>
@ -244,7 +249,7 @@ export default function PaymentsDemoPage() {
<div className="grid gap-3 md:grid-cols-[160px_auto_1fr]">
<Input value={emailCount} onChange={(e) => setEmailCount(e.target.value)} inputMode="numeric" />
<Button onClick={() => runAsynchronouslyWithAlert(sendTestEmails)}>Send quota emails</Button>
<Button onClick={() => runAsynchronouslyWithAlert(sendTestEmails)}>Send test emails</Button>
<Button variant="secondary" onClick={() => runAsynchronouslyWithAlert(runConfigCheck)}>
Check free/config guardrails
</Button>
@ -253,7 +258,7 @@ export default function PaymentsDemoPage() {
<div className="rounded-md border bg-white p-3 text-sm dark:bg-black">
<div><span className="font-medium">Project:</span> {project.displayName} ({project.id})</div>
<div><span className="font-medium">Selected team:</span> {user.selectedTeam?.displayName ?? "none"}</div>
<div><span className="font-medium">Manual Stripe end test:</span> buy Team/Growth, end that customer subscription in Stripe Connect, wait for the webhook, then refresh here. The paid plan should disappear and Free should return.</div>
<div><span className="font-medium">Manual Stripe end test:</span> buy Team Pro, end that customer subscription in Stripe Connect, wait for the webhook, then refresh here. The paid plan should disappear.</div>
</div>
{result && (