Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ NEXTAUTH_URL=http://localhost:3000
# test_ key ONLY (CLAUDE.md §9) — never exercise billing against the live key.
MOLLIE_API_KEY=

# --- Stripe platform webhooks (POST /api/webhooks/stripe) ---
# ADR-011 amendment. This app never creates Stripe charges (that's the backend,
# Job B); it returns an application fee when a connected account refunds a
# charge, and it records the fees it earns.
# Platform secret key (sk_test_/sk_live_). Unset -> the webhook 503s.
STRIPE_API_KEY=
# ONE url, TWO Stripe endpoints, one handler — because the two event scopes are
# ORTHOGONAL and Stripe will not merge them. Each endpoint has its own whsec_;
# either one alone is a working configuration, and the route 503s only when
# BOTH are unset.
#
# Signing secret for the platform's CONNECT (`connect: true`) endpoint —
# `charge.refunded` for every connected account. Stripe refuses to let a
# platform register a webhook ON a connected account, so these arrive here.
STRIPE_CONNECT_WEBHOOK_SECRET=
# Signing secret for the ACCOUNT-scoped (non-Connect) endpoint at the SAME url —
# `application_fee.created`. An ApplicationFee is a PLATFORM-owned object, so its
# event carries `account: null` and a Connect endpoint NEVER receives it
# (measured 2026-09-04, with a control). Stripe accepts a `connect: true`
# endpoint that lists this event with HTTP 200 and then never fires it, so the
# wrong configuration reads exactly like "no commission earned yet".
STRIPE_ACCOUNT_WEBHOOK_SECRET=

# --- Fleet telemetry roll-up (POST /api/telemetry/fleet) ---
# Shared bearer secret each tenant backend's FleetSummaryPushService must present
# (openssl rand -hex 32). Must match the same secret on the backend side. Unset -> the route 503s.
Expand Down
37 changes: 22 additions & 15 deletions app/(control)/admin/billing/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { db } from "@/lib/db";
import { eur, shortDate } from "@/lib/format";
import { BILLING_INTERVALS } from "@/lib/billing";
import CancelSubscriptionButton from "@/components/control/CancelSubscriptionButton";
import CopyField from "@/components/control/CopyField";
import BillingIdentityForm from "@/components/control/BillingIdentityForm";
import RecheckVatButton from "@/components/control/RecheckVatButton";
import { isInvoiceable } from "@/lib/billing-identity";
Expand All @@ -16,6 +15,11 @@ import { planDeletionVerdict, settledOrInFlight } from "@/lib/plan-deletion";
import DeletePlanForm from "@/components/control/DeletePlanForm";
import TrialPanel from "@/components/control/TrialPanel";
import PlanPaymentsList from "@/components/control/PlanPaymentsList";
import AdminPaymentsModePanel from "@/components/control/AdminPaymentsModePanel";
import CommissionEarningsPanel from "@/components/control/CommissionEarningsPanel";
import OpenCheckoutPanel from "@/components/control/OpenCheckoutPanel";
import { asPaymentsMode } from "@/lib/payments-pricing";
import { loadTenantRegistry } from "@/lib/tenant-registry";

// Mollie interval string → control.admin.intervals key (display only).
const intervalKey = (mollie: string) =>
Expand Down Expand Up @@ -61,9 +65,9 @@ export default async function AdminBillingDetailPage({
hasMollieCustomer: Boolean(billing.mollieCustomerId),
});

const openCheckout = billing.payments.find(
(p) => p.checkoutUrl && (p.status === "open" || p.status === "pending"),
);
// Read-only seam (ADR-007) — shows what the box actually enforces, never writes it.
const registry = await loadTenantRegistry();
const registryTenant = registry.ok ? registry.tenants.find((t) => t.slug === billing.tenantSlug) : undefined;

return (
<div className="grid gap-10">
Expand All @@ -84,17 +88,7 @@ export default async function AdminBillingDetailPage({
</p>
</div>

{openCheckout && (
<section className="hand-drawn-border bg-card p-5">
<h2 className="font-hand text-2xl font-bold">{t("billingDetail.checkoutTitle")}</h2>
<p className="mt-1 font-label text-sm text-muted-foreground">
{t("billingDetail.checkoutIntro")}
</p>
<div className="mt-3">
<CopyField value={openCheckout.checkoutUrl!} />
</div>
</section>
)}
<OpenCheckoutPanel locale={locale} payments={billing.payments} />

<section className="hand-drawn-border bg-card p-5">
<h2 className="font-hand text-2xl font-bold">{t("identity.title")}</h2>
Expand Down Expand Up @@ -123,6 +117,19 @@ export default async function AdminBillingDetailPage({
subscriptions on purpose: whether money is owed comes before what it costs. */}
<TrialPanel locale={locale} billingId={billing.id} trialEndsAt={billing.trialEndsAt} />

<AdminPaymentsModePanel
locale={locale}
tenantSlug={billing.tenantSlug}
billingMode={asPaymentsMode(billing.paymentsMode)}
billingBps={billing.paymentsCommissionBps}
registryTenant={registryTenant}
registryReadable={registry.ok}
/>

{/* Directly below the rate control: what the rate collected belongs beside
what the rate IS — that adjacency is what makes a wrong rate visible. */}
<CommissionEarningsPanel locale={locale} stripeAccount={registryTenant?.stripe_account} registryReadable={registry.ok} />

<section>
<h2 className="font-hand text-3xl font-bold">{t("billingDetail.subscriptions")}</h2>
<ul className="mt-4 grid gap-3">
Expand Down
15 changes: 15 additions & 0 deletions app/(control)/admin/signups/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { eur } from "@/lib/format";
import { loadTenantRegistry } from "@/lib/tenant-registry";
import { checkSlug } from "@/lib/slug-availability";
import { failedByAction } from "@/lib/email-delivery";
import { formatCommissionPercent } from "@/lib/payments-pricing";
import SignupActions from "@/components/control/SignupActions";

// Direct-restaurant signup pipeline (ADR-004). Leads land here via POST
Expand Down Expand Up @@ -103,6 +104,20 @@ export default async function AdminSignupsPage() {
<dd className="font-bold">{eur(s.quotedCents)}</dd>
</>
)}
{/* S3: absent on every lead captured before the payments pricing
mode choice shipped, same reasoning as the fields above. */}
{s.paymentsMode !== null && (
<>
<dt className="text-muted-foreground">{t("chosenPaymentsMode")}</dt>
<dd className="font-mono">
{s.paymentsMode === "commission"
? t("paymentsModeCommission", {
percent: formatCommissionPercent(s.paymentsCommissionBps ?? 0),
})
: t("paymentsModeFlat")}
</dd>
</>
)}
</dl>
)}
{s.message && (
Expand Down
28 changes: 28 additions & 0 deletions app/(control)/admin/tenants/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
missingPairedStripeAccount,
type RegistryTenant,
} from "@/lib/tenant-registry";
import { effectivePaymentsMode } from "@/lib/payments-mode-effective";
import { asPaymentsMode, formatCommissionPercent } from "@/lib/payments-pricing";

// The registry file changes underneath us (rsync on deploy-repo push) — always
// re-read instead of serving a build-time snapshot.
Expand All @@ -19,6 +21,8 @@ export const dynamic = "force-dynamic";
type BillingSummary = {
id: string;
subscriptions: { status: string; amountCents: number; interval: string }[];
paymentsMode: string;
paymentsCommissionBps: number;
};

// Shape of the "control.admin" translator handed down to the row helpers.
Expand Down Expand Up @@ -55,6 +59,27 @@ function BillingCell({ billing, t }: { billing?: BillingSummary; t: Translator }
);
}

/**
* Read-only payments-mode label (SOFRA-PAYMENTS-PRICING-MODE-PLAN S2b) — the
* EFFECTIVE mode, derived the same way the billing page's own panel derives it,
* never `billing.paymentsMode` alone: this page's whole reason to exist is
* showing what the box actually enforces, and the control that CHANGES it lives
* on `/admin/billing/[id]`, not here.
*/
function paymentsModeLabel(tenant: RegistryTenant, billing: BillingSummary | undefined, t: Translator) {
const intended = billing ? asPaymentsMode(billing.paymentsMode) : "flat";
const effective = effectivePaymentsMode({
intended,
registryBps: tenant.payments_commission_bps,
registryReadable: true, // this page only ever renders inside the registry.ok branch
});
const base =
effective.mode === "commission"
? t("tenants.paymentsModeCommission", { percent: formatCommissionPercent(tenant.payments_commission_bps ?? 0) })
: t("tenants.paymentsModeFlat");
return effective.pending ? `${base} ${t("tenants.paymentsModePending")}` : base;
}

function TenantCard({
tenant,
billing,
Expand Down Expand Up @@ -96,6 +121,7 @@ function TenantCard({
{/* classic/craft is a technical identifier — rendered raw like status */}
{t("tenants.template", { template: tenant.template ?? "classic" })}
</span>
<span className="block text-muted-foreground">{paymentsModeLabel(tenant, billing, t)}</span>
{/* An `acct_…` is a Stripe identifier, not a secret, and it is the one
fact that says whether this tenant can take a card at all. */}
{tenant.stripe_account && (
Expand Down Expand Up @@ -124,6 +150,8 @@ export default async function AdminTenantsPage() {
id: true,
tenantSlug: true,
subscriptions: { select: { status: true, amountCents: true, interval: true } },
paymentsMode: true,
paymentsCommissionBps: true,
},
});
const billingBySlug = new Map(billings.map((b) => [b.tenantSlug, b]));
Expand Down
9 changes: 9 additions & 0 deletions app/(control)/dashboard/clients/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import TenantDnsPanel from "@/components/control/TenantDnsPanel";
import { tenantDnsRecords } from "@/lib/tenant-dns-record";
import { checkDnsRecord } from "@/lib/tenant-dns-check";
import ClientPlanPanel from "@/components/control/ClientPlanPanel";
import ClientPaymentsModePanel from "@/components/control/ClientPaymentsModePanel";
import ClientChangeRequestForm from "@/components/control/ClientChangeRequestForm";
import NoteForm from "@/components/control/NoteForm";

Expand Down Expand Up @@ -138,6 +139,14 @@ export default async function ClientDetailPage({
{view.kind !== "none" && (
<>
<ClientPlanPanel locale={locale} billing={billing} />
{/* What Sofra charges for online payments, and the switch between the two
ways of charging it (S4). Posts the CLIENT id — never a tenant slug. */}
<ClientPaymentsModePanel
locale={locale}
clientId={client.id}
view={view}
billing={client.billing}
/>
<section className="hand-drawn-border bg-card p-6">
<h2 className="font-hand text-3xl font-bold">{t("changeRequest")}</h2>
<p className="mt-2 font-label text-muted-foreground">{t("changeRequestIntro")}</p>
Expand Down
19 changes: 18 additions & 1 deletion app/api/signup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { founderInbox, escapeHtml } from "@/lib/email";
import { guardIntake } from "@/lib/intake";
import { signupSchema } from "@/lib/validation";
import { audit } from "@/lib/audit";
import { sanitizeSignupConfiguration } from "@/lib/signup-configuration";
import {
sanitizeSignupConfiguration,
type StoredSignupConfiguration,
} from "@/lib/signup-configuration";
import { eur } from "@/lib/format";
import { loadTenantRegistry } from "@/lib/tenant-registry";
import { checkSlug } from "@/lib/slug-availability";
Expand Down Expand Up @@ -132,6 +135,19 @@ async function mintAccount(
* customer is still at the keyboard and one field away from succeeding, so asking
* is better than banking a lead nobody can act on until the slug is renegotiated.
*/
/**
* The founder's new-lead mail lists the quote, and under `commission` that total
* EXCLUDES the online-payments module — so the number alone reads as a cheaper
* plan with no visible reason for it. This row is what explains it, and it lives
* outside POST so the handler stays under its cognitive-complexity limit.
*/
function paymentsRow(config: StoredSignupConfiguration): string {
if (config.paymentsMode === "commission") {
return `commission (${config.paymentsCommissionBps ?? 0} bps)`;
}
return config.paymentsMode ?? "—";
}

export async function POST(request: Request) {
const guard = await guardIntake(request, "signup");
if ("response" in guard) return guard.response;
Expand Down Expand Up @@ -252,6 +268,7 @@ export async function POST(request: Request) {
["Tenant languages", config.languages ?? "—"],
["Currency", config.currency ?? "—"],
["Quoted", config.quotedCents === null ? "—" : `${eur(config.quotedCents)}/mo`],
["Payments", paymentsRow(config)],
],
}).catch(() => undefined);
}
Expand Down
Loading
Loading