page.tsx349 lines · main
1import { apiJson } from '../../../../lib/api';
2import { requireUser } from '../../../../lib/session';
3import { ManageBillingButton } from './manage-billing-button';
4import { UpgradeButtons } from './upgrade-buttons';
5
6export const metadata = { title: 'billing' };
7export const dynamic = 'force-dynamic';
8
9interface SubscriptionSummary {
10 tier: 'free' | 'pro' | 'team';
11 status: 'free' | 'trialing' | 'active' | 'past_due' | 'canceled';
12 currentPeriodEnd: string | null;
13 canceledAt: string | null;
14 polarCustomerId: string | null;
15}
16
17interface Plan {
18 tier: 'pro' | 'team';
19 productId: string;
20}
21
22interface BillingProfile {
23 legalName: string | null;
24 companyName: string | null;
25 companyRegistrationNumber: string | null;
26 vatId: string | null;
27 vatVerifiedAt: string | null;
28 addressLine1: string | null;
29 addressLine2: string | null;
30 addressCity: string | null;
31 addressPostalCode: string | null;
32 addressRegion: string | null;
33 addressCountry: string | null;
34}
35
36const TIER_INCLUDED: Record<
37 SubscriptionSummary['tier'],
38 Array<{ label: string; value: string }>
39> = {
40 free: [
41 { label: 'function invocations', value: '1M / mo' },
42 { label: 'database', value: '1 gb' },
43 { label: 'file storage', value: '1 gb' },
44 { label: 'bandwidth', value: '10 gb / mo' },
45 { label: 'realtime connections', value: '100 concurrent' },
46 { label: 'log retention', value: '7 days' },
47 ],
48 pro: [
49 { label: 'function invocations', value: '10M / mo' },
50 { label: 'database', value: '10 gb' },
51 { label: 'file storage', value: '50 gb' },
52 { label: 'bandwidth', value: '100 gb / mo' },
53 { label: 'realtime connections', value: '1,000 concurrent' },
54 { label: 'log retention', value: '30 days' },
55 ],
56 team: [
57 { label: 'function invocations', value: '100M / mo' },
58 { label: 'database', value: '100 gb' },
59 { label: 'file storage', value: '500 gb' },
60 { label: 'bandwidth', value: '1 tb / mo' },
61 { label: 'realtime connections', value: '10,000 concurrent' },
62 { label: 'log retention', value: '90 days' },
63 ],
64};
65
66const TIER_PRICE: Record<SubscriptionSummary['tier'], string> = {
67 free: '€0 / month',
68 pro: '€29 / month',
69 team: '€99 / month',
70};
71
72interface SlaRow {
73 uptime: string;
74 responseTarget: string;
75 supportResponse: string;
76 rollbackWindow: string;
77}
78
79const TIER_SLA: Record<SubscriptionSummary['tier'], SlaRow> = {
80 free: {
81 uptime: 'best-effort',
82 responseTarget: 'no target',
83 supportResponse: 'community (github + discord)',
84 rollbackWindow: '7 days log retention',
85 },
86 pro: {
87 uptime: '99.5% monthly',
88 responseTarget: 'p99 < 500ms invoke',
89 supportResponse: 'email · 24h business-day response',
90 rollbackWindow: '30 days log retention · 24h pg_dump',
91 },
92 team: {
93 uptime: '99.9% monthly',
94 responseTarget: 'p99 < 200ms invoke · p99 < 100ms realtime fan-out',
95 supportResponse: 'email · 4h business-day response · slack-connect on request',
96 rollbackWindow: '90 days log retention · 24h pg_dump + cross-region replica',
97 },
98};
99
100const STATUS_LABEL: Record<SubscriptionSummary['status'], string> = {
101 free: '—',
102 trialing: 'trialing',
103 active: 'active',
104 past_due: 'payment past due',
105 canceled: 'canceled',
106};
107
108function renewalDate(iso: string | null): string {
109 if (!iso) return '—';
110 return new Date(iso).toISOString().slice(0, 10);
111}
112
113export default async function BillingPage({
114 searchParams,
115}: {
116 searchParams: Promise<{ checkout?: string }>;
117}) {
118 await requireUser();
119 const { checkout } = await searchParams;
120
121 const subscription = await apiJson<SubscriptionSummary>('/v1/billing/subscription').catch(() => ({
122 tier: 'free' as const,
123 status: 'free' as const,
124 currentPeriodEnd: null,
125 canceledAt: null,
126 polarCustomerId: null,
127 }));
128
129 const { plans } = await apiJson<{ plans: Plan[] }>('/v1/billing/plans').catch(() => ({
130 plans: [] as Plan[],
131 }));
132
133 // Fetch billing address from the profile endpoint
134 const profile = await apiJson<BillingProfile>('/v1/me').catch(() => null);
135
136 const tier = subscription.tier;
137 const isCheckoutSuccess = checkout === 'success';
138
139 return (
140 <div className="flex max-w-4xl flex-col gap-10 pb-12">
141 {isCheckoutSuccess ? (
142 <div className="rounded-md border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-4 py-3 font-mono text-sm text-[var(--color-primary)]">
143 payment received · your new plan is active · it can take a few seconds for the tier pill
144 to update below
145 </div>
146 ) : null}
147
148 <header>
149 <h1 className="font-mono text-lg text-[var(--color-text)]">billing</h1>
150 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
151 plan, usage allowance, payments, invoices, cards, and cancellation are handled
152 through the polar.sh customer portal.
153 </p>
154 </header>
155
156 {/* Current plan card */}
157 <section className="flex flex-col gap-4 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
158 <div className="flex flex-wrap items-start justify-between gap-4">
159 <div className="flex flex-col gap-1">
160 <p className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--color-text-subtle)]">
161 current plan
162 </p>
163 <div className="flex items-baseline gap-3">
164 <span className="rounded bg-[var(--color-primary-subtle)] px-2 py-0.5 font-mono text-sm text-[var(--color-primary)]">
165 {tier}
166 </span>
167 <span className="font-mono text-xs text-[var(--color-text-muted)]">
168 {TIER_PRICE[tier]}
169 </span>
170 </div>
171 </div>
172 <dl className="grid grid-cols-[1fr] gap-y-2 sm:grid-cols-[110px_1fr] font-mono text-xs">
173 <dt className="text-[var(--color-text-subtle)]">status</dt>
174 <dd className="text-[var(--color-text)]">
175 {STATUS_LABEL[subscription.status] ?? subscription.status}
176 {subscription.canceledAt ? (
177 <span className="ml-2 text-[var(--color-text-subtle)]">
178 cancels {renewalDate(subscription.currentPeriodEnd)}
179 </span>
180 ) : null}
181 </dd>
182
183 <dt className="text-[var(--color-text-subtle)]">
184 {subscription.canceledAt ? 'access until' : 'renews'}
185 </dt>
186 <dd className="text-[var(--color-text)]">
187 {renewalDate(subscription.currentPeriodEnd)}
188 </dd>
189
190 <dt className="text-[var(--color-text-subtle)]">processor</dt>
191 <dd className="text-[var(--color-text-muted)]">
192 Polar.sh{' '}
193 <span className="text-[var(--color-text-subtle)]">· mavi Pay arrives later</span>
194 </dd>
195 </dl>
196 </div>
197
198 <div className="flex flex-wrap gap-2 border-t border-[var(--color-border-subtle)] pt-4">
199 {plans.length > 0 ? (
200 <UpgradeButtons plans={plans} currentTier={tier} />
201 ) : (
202 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
203 plan switching unavailable — Polar product env vars are not configured on the api.
204 </p>
205 )}
206 {subscription.polarCustomerId ? (
207 <ManageBillingButton label="payment method · invoices · cancel" />
208 ) : null}
209 </div>
210 </section>
211
212 {/* Included allowance card */}
213 <section className="flex flex-col gap-4">
214 <div className="flex items-baseline justify-between">
215 <h2 className="font-mono text-sm text-[var(--color-text)]">included this month</h2>
216 <span className="font-mono text-xs text-[var(--color-text-subtle)]">
217 current month so far · live
218 </span>
219 </div>
220 <dl className="grid grid-cols-1 gap-x-3 sm:grid-cols-[200px_1fr] gap-y-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 font-mono text-sm">
221 {TIER_INCLUDED[tier].map((row) => (
222 <FragmentRow key={row.label} label={row.label} value={row.value} />
223 ))}
224 </dl>
225 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
226 today these are soft caps — the meter records every invocation but billing only
227 tracks the included bucket. overage billing arrives with the public beta. your
228 per-project page shows live usage against each cap.
229 </p>
230 </section>
231
232 {/* Billing address — pulled from profile */}
233 <BillingAddressCard profile={profile} />
234
235 {/* SLA card */}
236 <section className="flex flex-col gap-4">
237 <div className="flex items-baseline justify-between">
238 <h2 className="font-mono text-sm text-[var(--color-text)]">service-level commitment</h2>
239 <span className="font-mono text-xs text-[var(--color-text-subtle)]">{tier} tier</span>
240 </div>
241 <dl className="grid grid-cols-1 gap-x-3 sm:grid-cols-[200px_1fr] gap-y-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 font-mono text-sm">
242 <FragmentRow label="uptime" value={TIER_SLA[tier].uptime} />
243 <FragmentRow label="response target" value={TIER_SLA[tier].responseTarget} />
244 <FragmentRow label="support" value={TIER_SLA[tier].supportResponse} />
245 <FragmentRow label="rollback window" value={TIER_SLA[tier].rollbackWindow} />
246 </dl>
247 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
248 live operational health is at <a className="underline underline-offset-2" href="https://docs.briven.tech/status">docs.briven.tech/status</a>.
249 formal credit-eligible SLA terms ship with the public beta — current targets are
250 operational commitments, not contractual.
251 </p>
252 </section>
253
254 {/* Invoices / orders pointer */}
255 <section className="flex flex-col gap-3">
256 <h2 className="font-mono text-sm text-[var(--color-text)]">invoices</h2>
257 <div className="rounded-md border border-dashed border-[var(--color-border)] bg-transparent p-6 font-mono text-sm text-[var(--color-text-muted)]">
258 {subscription.polarCustomerId ? (
259 <>
260 <p>invoices and order history live on the polar customer portal.</p>
261 <div className="mt-3">
262 <ManageBillingButton label="open polar portal" />
263 </div>
264 </>
265 ) : (
266 <p>no invoices yet. start a paid plan to see billing history.</p>
267 )}
268 </div>
269 </section>
270 </div>
271 );
272}
273
274function BillingAddressCard({ profile }: { profile: BillingProfile | null }) {
275 const hasAddress = profile?.addressLine1 || profile?.addressCity || profile?.addressCountry;
276 const hasCompany = profile?.companyName || profile?.companyRegistrationNumber || profile?.vatId;
277 const hasIdentity = profile?.legalName;
278
279 return (
280 <section className="flex flex-col gap-4">
281 <div className="flex items-baseline justify-between">
282 <h2 className="font-mono text-sm text-[var(--color-text)]">billing address</h2>
283 <a
284 href="/dashboard/settings"
285 className="font-mono text-xs text-[var(--color-text-link)] underline-offset-2 hover:underline"
286 >
287 edit in settings →
288 </a>
289 </div>
290 <dl className="grid grid-cols-1 gap-x-3 sm:grid-cols-[200px_1fr] gap-y-2 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 font-mono text-sm">
291 {hasIdentity ? (
292 <FragmentRow label="legal name" value={profile!.legalName ?? '—'} />
293 ) : null}
294 {hasCompany ? (
295 <>
296 {profile!.companyName ? (
297 <FragmentRow label="company" value={profile!.companyName} />
298 ) : null}
299 {profile!.companyRegistrationNumber ? (
300 <FragmentRow label="registration no." value={profile!.companyRegistrationNumber} />
301 ) : null}
302 {profile!.vatId ? (
303 <FragmentRow
304 label="vat id"
305 value={`${profile!.vatId}${profile!.vatVerifiedAt ? ' · verified ✓' : ''}`}
306 />
307 ) : null}
308 </>
309 ) : null}
310 {hasAddress ? (
311 <>
312 <FragmentRow
313 label="address"
314 value={[
315 profile!.addressLine1,
316 profile!.addressLine2,
317 profile!.addressCity,
318 profile!.addressPostalCode,
319 profile!.addressRegion,
320 ]
321 .filter(Boolean)
322 .join(', ')}
323 />
324 <FragmentRow label="country" value={profile!.addressCountry ?? '—'} />
325 </>
326 ) : !hasIdentity && !hasCompany ? (
327 <dt className="col-span-full text-center text-[var(--color-text-muted)]">
328 no billing address on file —{' '}
329 <a
330 href="/dashboard/settings"
331 className="text-[var(--color-text-link)] underline-offset-2 hover:underline"
332 >
333 add one in settings
334 </a>
335 </dt>
336 ) : null}
337 </dl>
338 </section>
339 );
340}
341
342function FragmentRow({ label, value }: { label: string; value: string }) {
343 return (
344 <>
345 <dt className="text-[var(--color-text-subtle)]">{label}</dt>
346 <dd className="text-[var(--color-text)]">{value}</dd>
347 </>
348 );
349}