diff --git a/app/(control)/dashboard/billing/page.tsx b/app/(control)/dashboard/billing/page.tsx
index 1bf184d..e229d4c 100644
--- a/app/(control)/dashboard/billing/page.tsx
+++ b/app/(control)/dashboard/billing/page.tsx
@@ -3,7 +3,12 @@ import { requirePartner } from "@/lib/rbac";
import { controlLocale } from "@/lib/control-locale";
import { db } from "@/lib/db";
import { eur, shortDate } from "@/lib/format";
-import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display";
+import {
+ intervalKeyOf,
+ nextChargeDate,
+ planState,
+ type PlanState,
+} from "@/lib/billing-display";
import StartPaymentButton from "@/components/control/StartPaymentButton";
export default async function DashboardBillingPage() {
@@ -12,11 +17,17 @@ export default async function DashboardBillingPage() {
const t = await getTranslations({ locale, namespace: "control.plan" });
// Plan-status node via if/else (avoids a nested ternary — Sonar S3358).
- const statusNode = (state: PlanState, startDate: Date | null, billingId: string) => {
+ //
+ // `nextChargeDate` rather than the raw `startDate` this used to print: that column is
+ // the FIRST recurring charge and is never advanced, so from month two onward it named
+ // a date in the past (see lib/billing-display.ts). Same defect, same fix, on both the
+ // reseller's page and the owner's card.
+ const statusNode = (sub: { startDate: Date | null; interval: string }, state: PlanState, billingId: string) => {
if (state === "active") {
+ const next = nextChargeDate(sub.startDate, sub.interval, new Date());
return (
- {startDate ? t("activeNextCharge", { date: shortDate(startDate) }) : t("active")}
+ {next ? t("activeNextCharge", { date: shortDate(next) }) : t("active")}
);
}
@@ -78,7 +89,7 @@ export default async function DashboardBillingPage() {
interval: t(`interval.${intervalKeyOf(sub.interval)}`),
})}
- {statusNode(state, sub.startDate, b.id)}
+ {statusNode(sub, state, b.id)}
>
) : (
{t("noPlan")}
diff --git a/app/(control)/dashboard/page.tsx b/app/(control)/dashboard/page.tsx
index 17214ad..d8e8480 100644
--- a/app/(control)/dashboard/page.tsx
+++ b/app/(control)/dashboard/page.tsx
@@ -1,75 +1,26 @@
-import { getTranslations } from "next-intl/server";
import { requirePartnerOrOwner } from "@/lib/rbac";
import { controlLocale } from "@/lib/control-locale";
import { db } from "@/lib/db";
-import { eur, shortDate } from "@/lib/format";
-import { intervalKeyOf, planState, type PlanState } from "@/lib/billing-display";
-import ClientForm from "@/components/control/ClientForm";
-import ClientStatusBadge from "@/components/control/ClientStatusBadge";
-import StartPaymentButton from "@/components/control/StartPaymentButton";
-import ActivatingPanel from "@/components/control/ActivatingPanel";
+import OwnerDashboard from "@/components/control/OwnerDashboard";
+import PartnerDashboard from "@/components/control/PartnerDashboard";
/**
- * Which "where is my restaurant" line the welcome hero shows — or none.
- * Extracted so the choice reads as a decision instead of a nested ternary
- * (Sonar S3358).
+ * `/dashboard` — shared by the reseller (PARTNER) and the restaurant owner (OWNER),
+ * who want almost nothing in common.
*
- * Returns null for an owner who is already activating: ``
- * says what is happening in full, and the hero's "start your subscription"
- * nudge directly contradicts a panel that opens with "your first payment went
- * through". A line that argues with the line below it is worse than no line.
- */
-function liveSinceLine(
- liveSince: Date | null,
- restaurant: string,
- isOwner: boolean,
- activating: boolean,
- tp: (key: string, values?: Record) => string,
-): string | null {
- if (liveSince) return tp("liveSince", { restaurant, date: shortDate(liveSince) });
- if (!isOwner) return tp("liveSinceUnknown", { restaurant });
- return activating ? null : tp("notLiveYet", { restaurant });
-}
-
-/**
- * What the payer can do about this plan right now. Written as guard clauses
- * rather than a chain of ternaries (Sonar S3358), mirroring `statusNode` in
- * `/dashboard/billing`.
+ * A partner reads a pipeline: many clients, each a row, the plans needing attention
+ * pulled to the top. An owner has exactly one plan and one question — *where is my
+ * restaurant app and how do I get into it?* Until O4 they shared one render, and the
+ * owner's half of it was a single sentence ("nothing to do here right now") with no
+ * amount, no next-charge date and no mention of their app at all.
*
- * `state` is only ever "pay" or "processing" here — the caller's filter admits
- * exactly those two — so "processing" is the mandate-validation window. An owner
- * gets it spelled out; a partner keeps the terse line, because a reseller reads
- * this queue as a pipeline and is not the one who just watched money leave their
- * account. Neither branch renders a pay button in that window: a second payment
- * is the trap it sets.
+ * The two views are now separate components; this page owns only the guard, the
+ * locale, and the one query both need.
*/
-function planAction(args: {
- state: PlanState;
- billingId: string;
- isOwner: boolean;
- locale: string;
- restaurant: string;
- tp: (key: string) => string;
-}) {
- const { state, billingId, isOwner, locale, restaurant, tp } = args;
- if (state === "pay") {
- return (
-
-
- {tp("firstChargeNote")}
-
- );
- }
- if (isOwner) return ;
- return {tp("processing")}
;
-}
-
export default async function DashboardPage() {
const user = await requirePartnerOrOwner();
const isOwner = user.role === "OWNER";
const locale = await controlLocale();
- const t = await getTranslations({ locale, namespace: "control.dashboard" });
- const tp = await getTranslations({ locale, namespace: "control.plan" });
// Billings scoped to the caller: an OWNER pays via payerUserId (ADR-004); a
// PARTNER via their CRM clients.
@@ -78,116 +29,33 @@ export default async function DashboardPage() {
include: {
client: true,
subscriptions: { orderBy: { createdAt: "desc" } },
- // Only first payments distinguish "pay" from "processing" (planState);
- // scope + bound so the unboundedly-growing recurring history is never
- // pulled into this request path.
+ // Only first payments distinguish "pay" from "processing" (planState); scope +
+ // bound so the unboundedly-growing recurring history is never pulled into this
+ // request path. KEEP THIS SCOPED now that an owner also sees a history list:
+ // widening it to "the 10 newest payments" would push the `first` payment out of
+ // the window once a plan has ten recurring charges, and planState would then
+ // read a paid, activating plan as "pay" — a pay button shown to somebody who
+ // already paid. OwnerDashboard fetches the history separately, and bounds it
+ // separately, for exactly that reason.
payments: { where: { sequenceType: "first" }, orderBy: { createdAt: "desc" }, take: 20 },
},
orderBy: { createdAt: "desc" },
});
- // Plans that still need the payer's attention (welcome hero): awaiting a
- // payment, or a payment being processed.
- const awaiting = billings.filter((b) => {
- const st = planState(b.subscriptions[0], b.payments);
- return st === "pay" || st === "processing";
- });
- // Reseller CRM — partners only; an owner has no clients.
- const clients = isOwner
- ? []
- : await db.client.findMany({ where: { partnerId: user.id }, orderBy: { updatedAt: "desc" } });
-
- return (
-
-
-
{isOwner ? t("ownerTitle") : t("title")}
-
{isOwner ? t("ownerIntro") : t("intro")}
-
-
- {awaiting.map((b) => {
- const sub = b.subscriptions[0];
- if (!sub) return null;
- // Owner billings carry no CRM client; the slug identifies the restaurant.
- const restaurant = b.client?.restaurantName ?? b.tenantSlug;
- const state = planState(sub, b.payments);
- // `liveSince` is set by the founder at onboarding, so for a reseller plan
- // (defined AFTER the tenant is live) its absence just means "date
- // unknown" — the tenant is live either way. A self-serve owner is the
- // opposite case: they signed up minutes ago and nothing has been
- // provisioned, so telling them their restaurant "is live" is simply false.
- const whereItStands = liveSinceLine(
- b.liveSince,
- restaurant,
- isOwner,
- state === "processing",
- tp,
- );
- return (
-
-
- {tp("welcomeKicker")}
-
-
- {tp("welcomeTitle", { name: user.name })}
-
- {whereItStands && {whereItStands}
}
-
- {tp("amountLine", {
- amount: eur(sub.amountCents),
- interval: tp(`interval.${intervalKeyOf(sub.interval)}`),
- })}
-
- {planAction({
- state,
- billingId: b.id,
- isOwner,
- locale,
- restaurant,
- tp,
- })}
-
- );
- })}
- {isOwner ? (
- awaiting.length === 0 && (
-
{t("ownerAllSet")}
- )
- ) : (
- <>
-
- {t("addClient")}
-
-
-
-
+ if (isOwner) {
+ return
;
+ }
- {clients.length === 0 ? (
-
{t("empty")}
- ) : (
-
- )}
- >
- )}
-
+ const clients = await db.client.findMany({
+ where: { partnerId: user.id },
+ orderBy: { updatedAt: "desc" },
+ });
+ return (
+
);
}
diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts
index 3c0bee6..0268437 100644
--- a/app/api/signup/route.ts
+++ b/app/api/signup/route.ts
@@ -46,6 +46,7 @@ const FOUNDER_FALLBACK_NOTES: Record = {
async function mintAccount(
outcome: Extract,
who: { email: string; contactName: string; restaurantName: string },
+ signupRequestId: string,
): Promise<{ account: boolean; founderOutcome: string }> {
let minted;
try {
@@ -53,6 +54,7 @@ async function mintAccount(
...who,
slug: outcome.slug,
amountCents: outcome.amountCents,
+ signupRequestId,
});
} catch (e) {
if (!(e instanceof SlugRaceLostError)) throw e;
@@ -189,11 +191,11 @@ export async function POST(request: Request) {
// ── Mint the account when the decision says so ──────────────────────────
const { account, founderOutcome } =
outcome.kind === "account"
- ? await mintAccount(outcome, {
- email,
- contactName: data.contactName,
- restaurantName: data.restaurantName,
- })
+ ? await mintAccount(
+ outcome,
+ { email, contactName: data.contactName, restaurantName: data.restaurantName },
+ signup.id,
+ )
: { account: false, founderOutcome: FOUNDER_FALLBACK_NOTES[outcome.reason] };
// ── Tell the founder what happened ─────────────────────────────────────
diff --git a/components/control/OwnerDashboard.tsx b/components/control/OwnerDashboard.tsx
new file mode 100644
index 0000000..cb4bdc6
--- /dev/null
+++ b/components/control/OwnerDashboard.tsx
@@ -0,0 +1,163 @@
+import { getTranslations } from "next-intl/server";
+import { db } from "@/lib/db";
+import { loadTenantRegistry } from "@/lib/tenant-registry";
+import { tenantStage } from "@/lib/tenant-liveness";
+import { probeTenantHealthy } from "@/lib/tenant-health";
+import OwnerPlanCard from "./OwnerPlanCard";
+
+/**
+ * The restaurant owner's dashboard (SOFRA-ONBOARDING-PLAN O4).
+ *
+ * Shows EVERY plan they pay for, not only the ones "awaiting attention" — that filter
+ * is what left an owner with an active subscription reading one sentence and no
+ * numbers. Each plan carries the panel that says where their app is and how to get
+ * into it, which is the piece O3 handed over.
+ */
+
+type PaymentRow = {
+ id: string;
+ billingId: string;
+ createdAt: Date;
+ sequenceType: string;
+ status: string;
+ amountCents: number;
+};
+
+type OwnerBilling = {
+ id: string;
+ tenantSlug: string;
+ liveSince: Date | null;
+ provisioningPrUrl: string | null;
+ client: { restaurantName: string } | null;
+ subscriptions: { status: string; amountCents: number; interval: string; startDate: Date | null }[];
+ payments: { sequenceType: string; status: string }[];
+};
+
+/** Newest payments shown in an owner's history, per plan. */
+const HISTORY_LIMIT = 10;
+
+/**
+ * Ceiling on the rows the history query may read, across every plan the owner holds.
+ *
+ * The cap exists so the query cannot grow with the age of an account, not because the
+ * number is meaningful: an owner holds ONE plan in practice (the self-serve signup
+ * mints exactly one), so 100 rows is over eight years of monthly charges for the
+ * realistic case and the slice below is exact.
+ *
+ * The one case where it is lossy is stated rather than hidden: an owner holding several
+ * plans, one of them far busier, could see the quiet plan's history thinned — the rows
+ * are taken newest-first across all of them. That is display-only history on a page
+ * whose purpose is the CURRENT plan, and the alternative (a query per plan) is the
+ * N+1 this replaced.
+ */
+const HISTORY_ROW_CAP = 100;
+
+/**
+ * The newest payments for every plan in one round trip, grouped by plan.
+ *
+ * Prisma has no per-group limit, so the slice happens in memory. Ordering is done by
+ * the database and preserved by `Map`/array insertion order, so each plan's list stays
+ * newest-first without a second sort.
+ */
+async function paymentHistory(billingIds: string[]) {
+ if (billingIds.length === 0) return new Map();
+ const rows = await db.billingPayment.findMany({
+ where: { billingId: { in: billingIds } },
+ orderBy: { createdAt: "desc" },
+ take: HISTORY_ROW_CAP,
+ });
+ const byBilling = new Map();
+ for (const row of rows) {
+ const list = byBilling.get(row.billingId) ?? [];
+ if (list.length < HISTORY_LIMIT) list.push(row);
+ byBilling.set(row.billingId, list);
+ }
+ return byBilling;
+}
+
+/**
+ * The registry `domain` for each slug, or an empty map when the registry cannot be
+ * read at all.
+ *
+ * An unreadable registry degrading to "no domain" is the fail-closed direction here:
+ * `tenantStage` then cannot reach "ready", so the worst case is a live owner briefly
+ * told their app is still being set up. The alternative — surfacing the registry read
+ * error on a customer's dashboard — reports one of our ops conditions to somebody who
+ * cannot act on it, and the founder already gets it loudly on `/admin/provision`.
+ */
+async function registryDomains(slugs: string[]): Promise