diff --git a/.env.example b/.env.example index c759ac7..ee717b5 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/(control)/admin/billing/[id]/page.tsx b/app/(control)/admin/billing/[id]/page.tsx index d1ca936..b7629c5 100644 --- a/app/(control)/admin/billing/[id]/page.tsx +++ b/app/(control)/admin/billing/[id]/page.tsx @@ -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"; @@ -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) => @@ -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 (
- {t("billingDetail.checkoutIntro")} -
-{t("changeRequestIntro")}
diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index c557d07..39dfbfd 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -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"; @@ -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; @@ -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); } diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..fd7ef08 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,154 @@ +// Stripe webhook — ONE url, TWO Stripe endpoints, one handler. +// +// 1. The `connect: true` endpoint (ADR-011 amendment, consequence 1 — "fee +// follows the refund"): Stripe REFUSES to let a platform register a webhook +// ON a connected account (measured), so connected-account events arrive +// platform-side and name their account via `event.account`. +// 2. An ACCOUNT-scoped (non-Connect) endpoint, for `application_fee.created`. +// An ApplicationFee is a PLATFORM-owned object, so its event carries +// `account: null` and a Connect endpoint NEVER receives it — measured, with +// a control, in lib/stripe-webhook-secrets.ts. Stripe accepts the wrong +// configuration (HTTP 200) and then silently never fires, which would make +// "no earnings recorded" indistinguishable from "no commission earned yet". +// +// Each endpoint has its own `whsec_`, so a delivery is verified against every +// configured secret and the scope that verified it is what the logs name. +// +// Deliberately NOT handled: `application_fee.refunded` / +// `application_fee.refund.updated`. The refunded side is already recorded by +// our own write path (lib/stripe-fee-refund.ts); a second source for the same +// fact is a reconciliation problem, not a feature. And `charge.refunded` can +// be processed BEFORE `application_fee.created` for a fast refund (the fee is +// created asynchronously — the runbook measured "within 5s"), which is safe +// only because the two tables are independent writers joined at read time. The +// natural next change — "look up the earned row while writing a refund" — +// would break exactly that. +// +// Every other Connect event type is deliberately left unhandled (ack 200, do +// nothing), not merely unimplemented: +// - `charge.dispute.*` is OUT OF SCOPE on purpose. For a Direct charge on a +// Standard connected account, dispute LIABILITY sits with the connected +// account, and whether Stripe reverses the application fee on a dispute +// is UNVERIFIED. Guessing here risks Sofra money on an untested branch; +// not handling it costs nothing today (the dispute is still visible in +// the connected account's own Stripe dashboard) and can be added once the +// behaviour is actually measured. +// - everything else Connect can send (account.updated, payout.*, …) is +// simply not this endpoint's job. +import { NextResponse } from "next/server"; +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { stripeConfigured, StripeError } from "@/lib/stripe"; +import { verifyingScope, webhookSecrets, type WebhookScope } from "@/lib/stripe-webhook-secrets"; +import { refundApplicationFeeForCharge } from "@/lib/stripe-fee-refund"; +import { recordApplicationFee } from "@/lib/stripe-fee-earned"; + +/** + * The ONE error taxonomy this endpoint has, shared by both branches rather than + * mirrored in each: a 404 from Stripe is ACKNOWLEDGED (a forged or unknown id, + * or a database restored across environments — there is nothing to do and a + * retry would not help), and anything else is treated as transient and answered + * 5xx so Stripe retries later. Swallowing that second case is silently lost + * revenue on the earned side and a fee never returned on the refunded one. + * + * Shared so the two can never drift apart, and so this file keeps ONE + * vocabulary for "what happened" — `what` and `scope` are what make a log line + * name the branch and the endpoint that produced it. + */ +async function acknowledge( + what: string, + scope: WebhookScope, + eventId: string, + run: () => Promise