Overview
The Billing API integrates with Stripe to manage team subscriptions, storage quotas, and pricing plans. Lawn offers two plans: Basic (5/monthwith100GBstorage)andPro(25/month with 1TB storage).
All billing functions are defined in convex/billing.ts.
Plans & Pricing
| Plan | Monthly Price | Storage Limit |
|---|
| Basic | $5 | 100 GB |
| Pro | $25 | 1 TB (1024 GB) |
Functions
createSubscriptionCheckout
Creates a Stripe Checkout session for subscribing to a plan.
import { useAction } from 'convex/react';
import { api } from '../convex/_generated/api';
function UpgradeButton({ teamId, plan }) {
const createCheckout = useAction(api.billing.createSubscriptionCheckout);
const handleUpgrade = async () => {
const { url } = await createCheckout({
teamId,
plan, // 'basic' or 'pro'
successUrl: window.location.origin + '/team/billing/success',
cancelUrl: window.location.origin + '/team/billing',
});
if (url) {
window.location.href = url;
}
};
return <button onClick={handleUpgrade}>Upgrade to {plan}</button>;
}
The team to create subscription for
URL to redirect to after successful payment
URL to redirect to if checkout is canceled
Stripe Checkout session ID
Stripe Checkout URL to redirect user to
Permissions: Requires owner role in the team
Implementation: convex/billing.ts:15
If the team already has an active subscription, this will throw an error. Use createCustomerPortalSession to manage existing subscriptions.
createCustomerPortalSession
Creates a Stripe Customer Portal session for managing an existing subscription.
import { useAction } from 'convex/react';
import { api } from '../convex/_generated/api';
function ManageBillingButton({ teamId }) {
const createPortal = useAction(api.billing.createCustomerPortalSession);
const openBillingPortal = async () => {
const { url } = await createPortal({
teamId,
returnUrl: window.location.origin + '/team/billing',
});
window.location.href = url;
};
return <button onClick={openBillingPortal}>Manage Subscription</button>;
}
The team to manage billing for
URL to return to after managing billing
Stripe Customer Portal URL
Permissions: Requires owner role in the team
Implementation: convex/billing.ts:68
The Customer Portal allows team owners to update payment methods, view invoices, cancel subscriptions, and change plans.
getTeamBilling
Retrieves complete billing information for a team.
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function BillingDashboard({ teamId }) {
const billing = useQuery(api.billing.getTeamBilling, { teamId });
if (!billing) return <div>Loading...</div>;
const storagePercent = (billing.storageUsedBytes / billing.storageLimitBytes) * 100;
return (
<div>
<h2>Billing & Usage</h2>
<div className="plan-info">
<strong>{billing.plan}</strong> Plan
<span>${billing.monthlyPriceUsd}/month</span>
</div>
<div className="storage">
<h3>Storage</h3>
<div className="progress-bar">
<div style={{ width: `${storagePercent}%` }} />
</div>
<p>
{formatBytes(billing.storageUsedBytes)} of {formatBytes(billing.storageLimitBytes)} used
</p>
</div>
{billing.hasActiveSubscription && (
<div className="subscription-status">
Status: {billing.subscriptionStatus}
{billing.currentPeriodEnd && (
<span>Renews {new Date(billing.currentPeriodEnd).toLocaleDateString()}</span>
)}
</div>
)}
</div>
);
}
function formatBytes(bytes: number): string {
const gb = bytes / (1024 ** 3);
return `${gb.toFixed(1)} GB`;
}
The team to get billing info for
Complete billing informationCurrent storage usage in bytes
Whether team has an active Stripe subscription
Stripe subscription status (active, trialing, past_due, etc.)
Subscription period end timestamp (milliseconds)
Current user’s role in the team
Permissions: Requires viewer role in the team
Implementation: convex/billing.ts:108
Storage Quota Enforcement
Video uploads automatically check storage quotas before accepting files:
// From convex/billingHelpers.ts
export const TEAM_PLAN_STORAGE_LIMIT_BYTES: Record<TeamPlan, number> = {
basic: 100 * GIBIBYTE, // 100 GB
pro: 1024 * GIBIBYTE, // 1 TB
};
When a video upload would exceed the team’s storage limit, the upload is rejected with an error message prompting the user to upgrade.
Checking Available Storage
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';
function StorageWarning({ teamId }) {
const billing = useQuery(api.billing.getTeamBilling, { teamId });
if (!billing) return null;
const percentUsed = (billing.storageUsedBytes / billing.storageLimitBytes) * 100;
if (percentUsed < 80) return null;
return (
<div className="warning">
<strong>Storage Warning</strong>
<p>
You've used {percentUsed.toFixed(0)}% of your storage.
{percentUsed >= 95 && ' Uploads may fail soon.'}
</p>
{billing.plan === 'basic' && (
<button>Upgrade to Pro for 1TB</button>
)}
</div>
);
}
Usage Examples
Complete Billing Flow
import { useQuery, useAction } from 'convex/react';
import { api } from '../convex/_generated/api';
function TeamBillingPage({ teamId }) {
const billing = useQuery(api.billing.getTeamBilling, { teamId });
const createCheckout = useAction(api.billing.createSubscriptionCheckout);
const createPortal = useAction(api.billing.createCustomerPortalSession);
if (!billing) return <div>Loading...</div>;
const canManageBilling = billing.role === 'owner';
const storagePercent = (billing.storageUsedBytes / billing.storageLimitBytes) * 100;
const handleUpgrade = async (plan: 'basic' | 'pro') => {
const { url } = await createCheckout({
teamId,
plan,
successUrl: `${window.location.origin}/team/${teamId}/billing/success`,
cancelUrl: `${window.location.origin}/team/${teamId}/billing`,
});
if (url) window.location.href = url;
};
const handleManage = async () => {
const { url } = await createPortal({
teamId,
returnUrl: `${window.location.origin}/team/${teamId}/billing`,
});
window.location.href = url;
};
return (
<div className="billing-page">
<section className="current-plan">
<h2>Current Plan</h2>
<div className="plan-card">
<h3>{billing.plan} Plan</h3>
<div className="price">${billing.monthlyPriceUsd}/month</div>
{billing.hasActiveSubscription && (
<div className="status">
<span className="badge">{billing.subscriptionStatus}</span>
{billing.currentPeriodEnd && (
<p>Renews on {new Date(billing.currentPeriodEnd).toLocaleDateString()}</p>
)}
</div>
)}
</div>
</section>
<section className="storage">
<h2>Storage Usage</h2>
<div className="storage-bar">
<div
className="storage-fill"
style={{
width: `${storagePercent}%`,
backgroundColor: storagePercent > 90 ? 'red' : 'green'
}}
/>
</div>
<p>
{formatBytes(billing.storageUsedBytes)} of {formatBytes(billing.storageLimitBytes)} used
({storagePercent.toFixed(1)}%)
</p>
</section>
{canManageBilling && (
<section className="actions">
<h2>Manage Billing</h2>
{!billing.hasActiveSubscription ? (
<div className="upgrade-options">
{billing.plan === 'basic' && (
<button onClick={() => handleUpgrade('pro')} className="primary">
Upgrade to Pro - $25/month
</button>
)}
{!billing.plan && (
<>
<button onClick={() => handleUpgrade('basic')}>
Get Basic - $5/month
</button>
<button onClick={() => handleUpgrade('pro')} className="primary">
Get Pro - $25/month
</button>
</>
)}
</div>
) : (
<button onClick={handleManage}>
Manage Subscription in Stripe
</button>
)}
</section>
)}
{!canManageBilling && (
<p className="permission-note">
Only team owners can manage billing.
</p>
)}
</div>
);
}
function formatBytes(bytes: number): string {
const gb = bytes / (1024 ** 3);
return `${gb.toFixed(1)} GB`;
}
Plan Comparison Component
function PricingTable({ currentPlan, onSelectPlan }) {
const plans = [
{
name: 'basic',
price: 5,
storage: '100 GB',
features: [
'Up to 100GB video storage',
'Unlimited team members',
'Timestamped comments',
'Password-protected sharing',
'Workflow management',
],
},
{
name: 'pro',
price: 25,
storage: '1 TB',
features: [
'Up to 1TB video storage',
'Unlimited team members',
'Timestamped comments',
'Password-protected sharing',
'Workflow management',
'Priority support',
],
},
];
return (
<div className="pricing-grid">
{plans.map(plan => (
<div
key={plan.name}
className={`plan-card ${currentPlan === plan.name ? 'current' : ''}`}
>
<h3>{plan.name}</h3>
<div className="price">${plan.price}<span>/month</span></div>
<div className="storage">{plan.storage} storage</div>
<ul className="features">
{plan.features.map(feature => (
<li key={feature}>{feature}</li>
))}
</ul>
<button
onClick={() => onSelectPlan(plan.name)}
disabled={currentPlan === plan.name}
>
{currentPlan === plan.name ? 'Current Plan' : `Choose ${plan.name}`}
</button>
</div>
))}
</div>
);
}
Subscription Status Values
| Status | Description |
|---|
active | Subscription is active and paid |
trialing | In trial period (if configured) |
past_due | Payment failed, retrying |
canceled | Subscription canceled, ends at period end |
unpaid | Payment failed, subscription paused |
incomplete | Initial payment not completed |
Lawn considers active, trialing, and past_due as valid states for accessing the platform. Other states may restrict functionality.