mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #51 from yusufipk/feat/cardless-trial
feat(billing): let people try the product before handing over a card
This commit is contained in:
@@ -38,6 +38,10 @@ OPENFRAME_REQUIRE_INVITE_CODE="true"
|
|||||||
# default: the rows only pay for themselves if you are running a signup funnel.
|
# default: the rows only pay for themselves if you are running a signup funnel.
|
||||||
# Everything is written to this instance's own database and sent nowhere.
|
# Everything is written to this instance's own database and sent nowhere.
|
||||||
OPENFRAME_ENABLE_ANALYTICS="false"
|
OPENFRAME_ENABLE_ANALYTICS="false"
|
||||||
|
# The date the cardless trial replaced the card-first one, as an ISO date. Set it
|
||||||
|
# to have /admin/growth compare signup-to-paid either side of the switchover;
|
||||||
|
# leave it empty and that section is simply not shown.
|
||||||
|
OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT=""
|
||||||
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
|
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
OpenFrame is a fair source video review and approval platform for teams that need clear feedback, version control, and client-friendly review links in one place. It supports collaborative review workflows out of the box and can be self-hosted with the Docker setup included in this repository.
|
OpenFrame is a fair source video review and approval platform for teams that need clear feedback, version control, and client-friendly review links in one place. It supports collaborative review workflows out of the box and can be self-hosted with the Docker setup included in this repository.
|
||||||
|
|
||||||
Prefer not to self-host? You can try OpenFrame at [open-frame.net](https://open-frame.net) with a 7-day free trial, then continue on the hosted plan starting at $10.
|
Prefer not to self-host? You can try OpenFrame at [open-frame.net](https://open-frame.net) with a 7-day free trial that needs no card, then continue on the hosted plan starting at $10.
|
||||||
|
|
||||||
## Product Screenshot
|
## Product Screenshot
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { after } from 'next/server';
|
import { after } from 'next/server';
|
||||||
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||||
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
import { isInviteCodeRequired, isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { getInvitationPreviewByToken } from '@/lib/invitations';
|
import { getInvitationPreviewByToken } from '@/lib/invitations';
|
||||||
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
|
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
|
||||||
import RegisterPageClient from './register-page-client';
|
import RegisterPageClient from './register-page-client';
|
||||||
@@ -40,6 +40,7 @@ export default async function RegisterPage({ searchParams }: RegisterPageProps)
|
|||||||
return (
|
return (
|
||||||
<RegisterPageClient
|
<RegisterPageClient
|
||||||
requireInviteCode={isInviteCodeRequired()}
|
requireInviteCode={isInviteCodeRequired()}
|
||||||
|
trialOnSignup={isStripeFeatureEnabled()}
|
||||||
googleEnabled={googleEnabled}
|
googleEnabled={googleEnabled}
|
||||||
githubEnabled={githubEnabled}
|
githubEnabled={githubEnabled}
|
||||||
invitation={invitation}
|
invitation={invitation}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface RegisterInvitation {
|
|||||||
|
|
||||||
interface RegisterPageClientProps {
|
interface RegisterPageClientProps {
|
||||||
requireInviteCode: boolean;
|
requireInviteCode: boolean;
|
||||||
|
/**
|
||||||
|
* Billing is on, so this account starts a free trial. False on a self-hosted
|
||||||
|
* instance, where there is nothing to trial and the promise would be a lie.
|
||||||
|
*/
|
||||||
|
trialOnSignup?: boolean;
|
||||||
googleEnabled: boolean;
|
googleEnabled: boolean;
|
||||||
githubEnabled: boolean;
|
githubEnabled: boolean;
|
||||||
invitation?: RegisterInvitation | null;
|
invitation?: RegisterInvitation | null;
|
||||||
@@ -30,6 +35,7 @@ interface RegisterPageClientProps {
|
|||||||
|
|
||||||
export default function RegisterPageClient({
|
export default function RegisterPageClient({
|
||||||
requireInviteCode,
|
requireInviteCode,
|
||||||
|
trialOnSignup = false,
|
||||||
googleEnabled,
|
googleEnabled,
|
||||||
githubEnabled,
|
githubEnabled,
|
||||||
invitation = null,
|
invitation = null,
|
||||||
@@ -156,6 +162,15 @@ export default function RegisterPageClient({
|
|||||||
Create Account
|
Create Account
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Join OpenFrame to collaborate on video projects</CardDescription>
|
<CardDescription>Join OpenFrame to collaborate on video projects</CardDescription>
|
||||||
|
{/* The landing page CTA promises a trial and no card. Say it again here,
|
||||||
|
where the promise is actually kept, rather than making people take the
|
||||||
|
previous page's word for it. Invitees are joining someone else's
|
||||||
|
workspace, so the trial is not what brought them. */}
|
||||||
|
{trialOnSignup && !isInvitationFlow && (
|
||||||
|
<CardDescription>
|
||||||
|
Your 7-day free trial starts as soon as you create the account. No credit card.
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* OAuth Buttons */}
|
{/* OAuth Buttons */}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Header } from '@/components/layout';
|
import { Header, TrialBanner } from '@/components/layout';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { hasAppNavigationAccess } from '@/lib/route-access';
|
import { hasAppNavigationAccess } from '@/lib/route-access';
|
||||||
|
import { getTrialNotice } from '@/lib/billing';
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const showAppNavigation = session?.user?.id
|
const userId = session?.user?.id;
|
||||||
? await hasAppNavigationAccess(session.user.id)
|
const [showAppNavigation, trialNotice] = await Promise.all([
|
||||||
: false;
|
userId ? hasAppNavigationAccess(userId) : false,
|
||||||
|
userId ? getTrialNotice(userId) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col">
|
<div className="relative flex min-h-screen flex-col">
|
||||||
<Header user={session?.user ?? null} showAppNavigation={showAppNavigation} />
|
<Header user={session?.user ?? null} showAppNavigation={showAppNavigation} />
|
||||||
|
{trialNotice ? <TrialBanner notice={trialNotice} /> : null}
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ interface BillingOverview {
|
|||||||
hasRecoverableSubscription: boolean;
|
hasRecoverableSubscription: boolean;
|
||||||
hasActiveTrial: boolean;
|
hasActiveTrial: boolean;
|
||||||
hasBillingAccess: boolean;
|
hasBillingAccess: boolean;
|
||||||
isTrialEligible: boolean;
|
isPaid: boolean;
|
||||||
priceId: string | null;
|
priceId: string | null;
|
||||||
currentPeriodEnd: string | null;
|
currentPeriodEnd: string | null;
|
||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
@@ -365,14 +365,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!billing.subscription.hasActiveSubscription &&
|
{!billing.subscription.hasActiveSubscription &&
|
||||||
!billing.subscription.hasActiveTrial &&
|
billing.subscription.hasActiveTrial &&
|
||||||
billing.subscription.isTrialEligible &&
|
billing.subscription.trialEndsAt ? (
|
||||||
billing.checkoutAvailable ? (
|
|
||||||
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
|
<div className="rounded-md border border-primary/30 bg-primary/5 p-4 space-y-2">
|
||||||
<p className="text-sm font-semibold">Start your 7-day free trial</p>
|
<p className="text-sm font-semibold">
|
||||||
|
Your free trial runs until{' '}
|
||||||
|
{new Date(billing.subscription.trialEndsAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Get full access to all features — no charge until the trial ends. Cancel
|
Every feature is on and no card is on file. The trial covers one workspace and
|
||||||
anytime.
|
one project. Subscribing starts your paid month straight away, so there is no
|
||||||
|
reason to do it before you are ready.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -388,9 +391,7 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
: 'Subscription canceled. Access remains active until the end of the current billing period.'
|
: 'Subscription canceled. Access remains active until the end of the current billing period.'
|
||||||
: 'Paid account with workspace creation unlocked.'
|
: 'Paid account with workspace creation unlocked.'
|
||||||
: billing.subscription.hasActiveTrial
|
: billing.subscription.hasActiveTrial
|
||||||
? 'Trial access is active.'
|
? 'Free trial, no card required.'
|
||||||
: billing.subscription.isTrialEligible
|
|
||||||
? "You haven't started your free trial yet."
|
|
||||||
: 'Billing access has ended.'}
|
: 'Billing access has ended.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,13 +434,15 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Deliberately not conditioned on `billingAccessEndedAt`: an account that
|
||||||
|
only ever had the cardless trial never gets one written, and it is exactly
|
||||||
|
that account which most needs to be told its work is still recoverable. */}
|
||||||
{!billing.subscription.hasBillingAccess &&
|
{!billing.subscription.hasBillingAccess &&
|
||||||
billing.subscription.billingAccessEndedAt &&
|
|
||||||
billing.subscription.storageCleanupEligibleAt ? (
|
billing.subscription.storageCleanupEligibleAt ? (
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
<p className="text-sm text-amber-700 dark:text-amber-400">
|
||||||
Stored media cleanup is scheduled after{' '}
|
Nothing has been deleted. Your projects and media are kept until{' '}
|
||||||
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()}{' '}
|
{new Date(billing.subscription.storageCleanupEligibleAt).toLocaleDateString()};
|
||||||
unless billing is restored first.
|
subscribe before then and everything is where you left it.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -479,8 +482,6 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Redirecting...
|
Redirecting...
|
||||||
</>
|
</>
|
||||||
) : billing.subscription.isTrialEligible ? (
|
|
||||||
'Start Free Trial'
|
|
||||||
) : (
|
) : (
|
||||||
'Upgrade with Stripe'
|
'Upgrade with Stripe'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -240,6 +240,67 @@ export default async function AdminGrowthPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{scoreboard.cohorts ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Card-first against cardless trial</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Accounts created in the {scoreboard.cohorts.windowDays} days either side of{' '}
|
||||||
|
{formatDate(scoreboard.cohorts.cutover)}, each given{' '}
|
||||||
|
{scoreboard.cohorts.observationDays} days from signup to convert. The rate to read is
|
||||||
|
signup to paid: dropping the card requirement multiplies trials, so trial to paid can
|
||||||
|
fall while more people pay.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
|
<th className="py-2 pr-4 font-medium">Cohort</th>
|
||||||
|
<th className="py-2 pr-4 font-medium">Window</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Signup</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Trial</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Paid</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Signup to paid</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Trial to paid</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{scoreboard.cohorts.rows.map((row) => (
|
||||||
|
<tr key={row.cohort} className="border-b last:border-0">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
{row.cohort === 'CARDLESS' ? 'cardless' : 'card first'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDate(row.windowStart)} to {formatDate(row.windowEnd)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
|
||||||
|
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
|
||||||
|
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
|
||||||
|
<td className="py-2 pr-4 text-right tabular-nums">
|
||||||
|
<Rate
|
||||||
|
rate={row.signups > 0 ? row.paid / row.signups : null}
|
||||||
|
of={row.signups}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right tabular-nums">
|
||||||
|
<Rate rate={row.trials > 0 ? row.paid / row.trials : null} of={row.trials} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{scoreboard.cohorts.windowDays === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
Nothing to compare yet. The first cardless signups reach the end of their{' '}
|
||||||
|
{scoreboard.cohorts.observationDays}-day window {scoreboard.cohorts.observationDays}{' '}
|
||||||
|
days after the switchover.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">By source</CardTitle>
|
<CardTitle className="text-base">By source</CardTitle>
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ import {
|
|||||||
createVerificationToken,
|
createVerificationToken,
|
||||||
isEmailVerificationEnabled,
|
isEmailVerificationEnabled,
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
|
warnIfTrialsSkipVerification,
|
||||||
} from '@/lib/email-verification';
|
} from '@/lib/email-verification';
|
||||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
import {
|
||||||
|
isDisposableEmailDomain,
|
||||||
|
isValidEmailAddress,
|
||||||
|
normalizeEmail,
|
||||||
|
} from '@/lib/email-validation';
|
||||||
|
import { startCardlessTrial } from '@/lib/billing';
|
||||||
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
||||||
import { readRequestVisitor } from '@/lib/analytics/visitor';
|
import { readRequestVisitor } from '@/lib/analytics/visitor';
|
||||||
|
|
||||||
@@ -49,6 +55,12 @@ export async function POST(request: NextRequest) {
|
|||||||
return apiErrors.validationError('Invalid email format');
|
return apiErrors.validationError('Invalid email format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checked before the invitation branch reads its token, but only applied to
|
||||||
|
// people signing themselves up: an invited collaborator was vouched for by a
|
||||||
|
// paying customer, and refusing their address breaks that customer's review
|
||||||
|
// rather than stopping anyone from farming trials.
|
||||||
|
const isDisposableAddress = isDisposableEmailDomain(normalizedEmail);
|
||||||
|
|
||||||
// Allow registration via a valid invitation token OR global invite code.
|
// Allow registration via a valid invitation token OR global invite code.
|
||||||
let invitationIsValid = false;
|
let invitationIsValid = false;
|
||||||
let validatedInvitationToken: string | null = null;
|
let validatedInvitationToken: string | null = null;
|
||||||
@@ -85,6 +97,12 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!invitationIsValid && isDisposableAddress) {
|
||||||
|
return apiErrors.badRequest(
|
||||||
|
'Please sign up with a permanent email address. Disposable mailboxes are not accepted.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!password || typeof password !== 'string' || password.length < 8 || password.length > 128) {
|
if (!password || typeof password !== 'string' || password.length < 8 || password.length > 128) {
|
||||||
return apiErrors.badRequest('Password must be between 8 and 128 characters');
|
return apiErrors.badRequest('Password must be between 8 and 128 characters');
|
||||||
}
|
}
|
||||||
@@ -142,6 +160,15 @@ export async function POST(request: NextRequest) {
|
|||||||
visitor: await readRequestVisitor(request),
|
visitor: await readRequestVisitor(request),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// With SMTP configured the trial starts when the address is proven, not here.
|
||||||
|
// Without it there is no verification step to hang the trial on and the
|
||||||
|
// account is already marked verified above, so withholding the trial would
|
||||||
|
// just lock the user out of an instance that has billing switched on.
|
||||||
|
if (!emailVerificationRequired) {
|
||||||
|
warnIfTrialsSkipVerification();
|
||||||
|
await startCardlessTrial(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Send verification email if SMTP is configured
|
// Send verification email if SMTP is configured
|
||||||
if (emailVerificationRequired) {
|
if (emailVerificationRequired) {
|
||||||
const verificationToken = await createVerificationToken(normalizedEmail);
|
const verificationToken = await createVerificationToken(normalizedEmail);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import {
|
import { getOrCreateStripeCustomerId, getStripeCheckoutState } from '@/lib/billing';
|
||||||
DEFAULT_TRIAL_PERIOD_DAYS,
|
|
||||||
getOrCreateStripeCustomerId,
|
|
||||||
getStripeCheckoutState,
|
|
||||||
} from '@/lib/billing';
|
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
|
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
|
||||||
@@ -73,11 +69,13 @@ export async function POST(request: NextRequest) {
|
|||||||
metadata: {
|
metadata: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
},
|
},
|
||||||
|
// No trial here. The free trial is granted in the product when the email
|
||||||
|
// address is verified, so by the time anyone reaches checkout they have
|
||||||
|
// already had it and this subscription bills immediately.
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
metadata: {
|
metadata: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
},
|
},
|
||||||
...(checkoutState.isTrialEligible ? { trial_period_days: DEFAULT_TRIAL_PERIOD_DAYS } : {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function GET() {
|
|||||||
hasRecoverableSubscription: billing.subscription.hasRecoverableSubscription,
|
hasRecoverableSubscription: billing.subscription.hasRecoverableSubscription,
|
||||||
hasActiveTrial: billing.subscription.hasActiveTrial,
|
hasActiveTrial: billing.subscription.hasActiveTrial,
|
||||||
hasBillingAccess: billing.subscription.hasBillingAccess,
|
hasBillingAccess: billing.subscription.hasBillingAccess,
|
||||||
isTrialEligible: billing.subscription.isTrialEligible,
|
isPaid: billing.subscription.isPaid,
|
||||||
priceId: billing.subscription.stripePriceId,
|
priceId: billing.subscription.stripePriceId,
|
||||||
currentPeriodEnd: billing.subscription.currentPeriodEnd?.toISOString() ?? null,
|
currentPeriodEnd: billing.subscription.currentPeriodEnd?.toISOString() ?? null,
|
||||||
cancelAtPeriodEnd: billing.subscription.cancelAtPeriodEnd ?? false,
|
cancelAtPeriodEnd: billing.subscription.cancelAtPeriodEnd ?? false,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { db } from '@/lib/db';
|
|||||||
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
import { auth, checkWorkspaceAccess } from '@/lib/auth';
|
||||||
import { ProjectVisibility } from '@prisma/client';
|
import { ProjectVisibility } from '@prisma/client';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
import { buildBillingAccessWhereInput } from '@/lib/billing';
|
import { buildBillingAccessWhereInput, isPaidTier } from '@/lib/billing';
|
||||||
|
import { TRIAL_PROJECT_LIMIT } from '@/lib/trial-limits';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
@@ -161,6 +162,26 @@ export async function POST(request: NextRequest) {
|
|||||||
return apiErrors.forbidden('Only workspace owners and admins can create projects');
|
return apiErrors.forbidden('Only workspace owners and admins can create projects');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Counted against the workspace owner rather than the caller, because that is
|
||||||
|
// the account being billed: `ownerId` on the project below is the workspace
|
||||||
|
// owner too. A workspace admin on somebody else's trial hits the same ceiling.
|
||||||
|
const owner = await db.user.findUnique({
|
||||||
|
where: { id: workspace.ownerId },
|
||||||
|
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (owner && !isPaidTier(owner)) {
|
||||||
|
const ownedProjectCount = await db.project.count({
|
||||||
|
where: { ownerId: workspace.ownerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ownedProjectCount >= TRIAL_PROJECT_LIMIT) {
|
||||||
|
return apiErrors.forbidden(
|
||||||
|
'Your free trial covers one project at a time. Delete the existing project or subscribe to run more in parallel.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const project = await db.$transaction(async (tx) => {
|
const project = await db.$transaction(async (tx) => {
|
||||||
const createdProject = await tx.project.create({
|
const createdProject = await tx.project.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
+5
-5
@@ -47,13 +47,13 @@ export default function RefundPolicyPage() {
|
|||||||
<p>
|
<p>
|
||||||
All new accounts are eligible for a{' '}
|
All new accounts are eligible for a{' '}
|
||||||
<strong className="text-foreground">7-day free trial</strong> with full access to paid
|
<strong className="text-foreground">7-day free trial</strong> with full access to paid
|
||||||
features. We strongly encourage you to evaluate the Service fully during this period
|
features. The trial requires no credit card and collects no payment. We strongly
|
||||||
before subscribing.
|
encourage you to evaluate the Service fully during this period before subscribing.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
You may cancel at any time during your free trial without being charged. If you do not
|
The trial never activates a paid plan by itself. When it ends, paid features stop
|
||||||
cancel before the trial ends, your chosen plan will automatically activate and payment
|
until you choose to subscribe, and a subscription is charged as soon as you complete
|
||||||
will be collected.
|
checkout.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -58,7 +58,7 @@ export default function TermsOfServicePage() {
|
|||||||
OpenFrame is a video review and approval platform that enables creative professionals
|
OpenFrame is a video review and approval platform that enables creative professionals
|
||||||
and their clients to collaborate on video projects through timestamped comments,
|
and their clients to collaborate on video projects through timestamped comments,
|
||||||
annotations, version management, and approval workflows. The Service is offered on a
|
annotations, version management, and approval workflows. The Service is offered on a
|
||||||
subscription basis with a free trial period.
|
subscription basis with a free trial period that requires no payment details.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -96,11 +96,12 @@ export default function TermsOfServicePage() {
|
|||||||
5. Subscriptions and Free Trial
|
5. Subscriptions and Free Trial
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
Certain features of the Service require a paid subscription. We offer a{' '}
|
Certain features of the Service require a paid subscription. Every new account
|
||||||
<strong className="text-foreground">7-day free trial</strong> for new accounts, during
|
receives a <strong className="text-foreground">7-day free trial</strong>, which starts
|
||||||
which you may access paid features at no charge. At the end of the trial period, your
|
once the account's email address is verified and requires no credit card. The
|
||||||
subscription will automatically convert to a paid plan unless you cancel before the
|
trial does not convert into a paid plan on its own and nothing is charged while it
|
||||||
trial ends.
|
runs. When it ends, access to paid features stops until you choose to subscribe, and a
|
||||||
|
subscription is billed as soon as you complete checkout.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
Subscription fees are billed in advance on a monthly or annual basis depending on the
|
Subscription fees are billed in advance on a monthly or annual basis depending on the
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
|||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const navbarRef = useRef<HTMLElement | null>(null);
|
const navbarRef = useRef<HTMLElement | null>(null);
|
||||||
const hostedCtaHref = isLoggedIn ? '/dashboard' : '/register';
|
const hostedCtaHref = isLoggedIn ? '/dashboard' : '/register';
|
||||||
|
// Same button, two audiences. Once you are signed in it goes to the dashboard,
|
||||||
|
// and offering a trial to someone who is already using the product reads as a
|
||||||
|
// mistake rather than as an offer.
|
||||||
|
const hostedCtaLabel = isLoggedIn ? 'Open dashboard' : 'Start 7-day free trial, no card required';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanupHandlers: Array<() => void> = [];
|
const cleanupHandlers: Array<() => void> = [];
|
||||||
@@ -263,12 +267,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
|||||||
href={hostedCtaHref}
|
href={hostedCtaHref}
|
||||||
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
|
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
|
||||||
>
|
>
|
||||||
Start free trial
|
{hostedCtaLabel}
|
||||||
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
7-day free trial · Flat $10/mo — no per-seat fees · No client accounts
|
No credit card · Flat $10/mo after, no per-seat fees · No client accounts
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -677,7 +681,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
|||||||
<span className="text-[#06b6d4]">/ month</span>
|
<span className="text-[#06b6d4]">/ month</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-6 text-sm text-muted-foreground">
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
Starts with a 7-day free trial. Cancel anytime.
|
Starts with a 7-day free trial. No credit card. Cancel anytime.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80">
|
<ul className="mb-8 flex-1 space-y-4 text-sm text-foreground/80">
|
||||||
@@ -722,7 +726,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
|||||||
href={hostedCtaHref}
|
href={hostedCtaHref}
|
||||||
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"
|
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"
|
||||||
>
|
>
|
||||||
Start free trial
|
{isLoggedIn ? 'Open dashboard' : 'Start free trial'}
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -855,7 +859,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Is there a free trial?',
|
q: 'Is there a free trial?',
|
||||||
a: 'Yes. Hosted Cloud starts with a 7-day free trial. After that it is a flat $10/mo — no per-seat or per-client fees.',
|
a: 'Yes. Hosted Cloud starts with a 7-day free trial and never asks for a card to begin it. After that it is a flat $10/mo, with no per-seat or per-client fees.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'How is this different from sending a Google Drive link?',
|
q: 'How is this different from sending a Google Drive link?',
|
||||||
@@ -906,7 +910,7 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
|||||||
href={hostedCtaHref}
|
href={hostedCtaHref}
|
||||||
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02] md:min-w-[240px]"
|
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02] md:min-w-[240px]"
|
||||||
>
|
>
|
||||||
Start free trial
|
{hostedCtaLabel}
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { Header } from './header';
|
export { Header } from './header';
|
||||||
|
export { TrialBanner } from './trial-banner';
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import type { TrialNotice } from '@/lib/billing';
|
||||||
|
|
||||||
|
function formatDate(value: Date) {
|
||||||
|
return value.toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trial deadline, said once at the top of the app.
|
||||||
|
*
|
||||||
|
* The `ended` case is the one that matters: access stops at the trial's end date
|
||||||
|
* but nothing is deleted for another fifteen days, and an account that is not
|
||||||
|
* told this reads a locked workspace as lost work. So the deletion date is
|
||||||
|
* stated as reassurance rather than as a threat.
|
||||||
|
*/
|
||||||
|
export function TrialBanner({ notice }: { notice: TrialNotice }) {
|
||||||
|
const isEnded = notice.kind === 'ended';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isEnded
|
||||||
|
? 'border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-300'
|
||||||
|
: 'border-b border-primary/30 bg-primary/5 px-4 py-2 text-sm text-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span>
|
||||||
|
{isEnded
|
||||||
|
? `Your free trial ended on ${formatDate(notice.endsAt)}.`
|
||||||
|
: `Your free trial ends on ${formatDate(notice.endsAt)}.`}
|
||||||
|
</span>
|
||||||
|
{notice.contentKeptUntil ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Nothing has been deleted. Your projects and media are kept until{' '}
|
||||||
|
{formatDate(notice.contentKeptUntil)}.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Link href="/settings" className="font-medium underline underline-offset-4">
|
||||||
|
{isEnded ? 'Subscribe to pick up where you left off' : 'See plan'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ interface ComparisonPageProps {
|
|||||||
|
|
||||||
export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
|
export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
|
||||||
const hostedCtaHref = isLoggedIn ? '/dashboard' : '/register';
|
const hostedCtaHref = isLoggedIn ? '/dashboard' : '/register';
|
||||||
|
const hostedCtaLabel = isLoggedIn ? 'Open dashboard' : 'Start 7-day free trial, no card required';
|
||||||
const competitorName = getCompetitorName(page) ?? 'Alternatives';
|
const competitorName = getCompetitorName(page) ?? 'Alternatives';
|
||||||
const relatedSlugs = page.relatedSlugs.filter((slug) => slug in comparisonPageMap);
|
const relatedSlugs = page.relatedSlugs.filter((slug) => slug in comparisonPageMap);
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
|
|||||||
href={hostedCtaHref}
|
href={hostedCtaHref}
|
||||||
className="group relative isolate inline-flex h-12 items-center justify-center overflow-hidden border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
|
className="group relative isolate inline-flex h-12 items-center justify-center overflow-hidden border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
|
||||||
>
|
>
|
||||||
Start free trial
|
{hostedCtaLabel}
|
||||||
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
<a
|
<a
|
||||||
@@ -143,8 +144,9 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
|
|||||||
Pricing comparison
|
Pricing comparison
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 max-w-3xl text-sm text-muted-foreground md:text-base">
|
<p className="mt-3 max-w-3xl text-sm text-muted-foreground md:text-base">
|
||||||
OpenFrame is $10/month flat with a 7-day free trial. You do not pay per team member,
|
OpenFrame is $10/month flat with a 7-day free trial that never asks for a card. You
|
||||||
collaborator, or guest reviewer. Self-hosting is free with Docker.
|
do not pay per team member, collaborator, or guest reviewer. Self-hosting is free
|
||||||
|
with Docker.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PricingComparison
|
<PricingComparison
|
||||||
@@ -204,7 +206,7 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
|
|||||||
href={hostedCtaHref}
|
href={hostedCtaHref}
|
||||||
className="inline-flex h-12 items-center justify-center border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground"
|
className="inline-flex h-12 items-center justify-center border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground"
|
||||||
>
|
>
|
||||||
Start free trial
|
{hostedCtaLabel}
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
<a
|
<a
|
||||||
href={seoConfig.githubUrl}
|
href={seoConfig.githubUrl}
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ interface CtaLinkProps {
|
|||||||
/**
|
/**
|
||||||
* A marketing call to action that reports the click.
|
* A marketing call to action that reports the click.
|
||||||
*
|
*
|
||||||
* Only the signup CTA counts. The same button reads "Start free trial" and
|
* Only the signup CTA counts. The same button points at `/dashboard` once you
|
||||||
* points at `/dashboard` once you are signed in, and an existing customer
|
* are signed in, and an existing customer clicking through to their own
|
||||||
* clicking through to their own dashboard is not a step in the acquisition
|
* dashboard is not a step in the acquisition funnel.
|
||||||
* funnel.
|
|
||||||
*/
|
*/
|
||||||
export function CtaLink({ href, className, children }: CtaLinkProps) {
|
export function CtaLink({ href, className, children }: CtaLinkProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+145
-1
@@ -77,6 +77,34 @@ export interface PaidAccountRow {
|
|||||||
selfReported: AcquisitionChannel | null;
|
selfReported: AcquisitionChannel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long an account gets to convert before its cohort is scored.
|
||||||
|
*
|
||||||
|
* Fixed rather than "since signup" so the two cohorts are compared over equal
|
||||||
|
* time. Without it the newer cohort is measured over a shorter life than the
|
||||||
|
* older one and always looks worse, whatever the change did.
|
||||||
|
*/
|
||||||
|
export const COHORT_OBSERVATION_DAYS = 30;
|
||||||
|
|
||||||
|
export type TrialCohort = 'CARD_FIRST' | 'CARDLESS';
|
||||||
|
|
||||||
|
export interface CohortRow {
|
||||||
|
cohort: TrialCohort;
|
||||||
|
windowStart: Date;
|
||||||
|
windowEnd: Date;
|
||||||
|
signups: number;
|
||||||
|
trials: number;
|
||||||
|
paid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CohortComparison {
|
||||||
|
cutover: Date;
|
||||||
|
observationDays: number;
|
||||||
|
/** Length of each side's window. Equal by construction; reported so it can be judged. */
|
||||||
|
windowDays: number;
|
||||||
|
rows: CohortRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Scoreboard {
|
export interface Scoreboard {
|
||||||
weeks: WeeklyRow[];
|
weeks: WeeklyRow[];
|
||||||
channels: ChannelRow[];
|
channels: ChannelRow[];
|
||||||
@@ -89,6 +117,8 @@ export interface Scoreboard {
|
|||||||
currentActivePaid: number | null;
|
currentActivePaid: number | null;
|
||||||
currentMrrCents: number | null;
|
currentMrrCents: number | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
/** Null until OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT names the switchover date. */
|
||||||
|
cohorts: CohortComparison | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeeklyQueryRow {
|
interface WeeklyQueryRow {
|
||||||
@@ -195,6 +225,118 @@ export function conversionRates(row: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day the cardless trial replaced the card-first one, if it has been set.
|
||||||
|
*
|
||||||
|
* Kept in the environment rather than in code because it is a fact about a
|
||||||
|
* deployment, not about the product: a self-hosted instance never switched over
|
||||||
|
* at all, and the hosted one only knows the date once it has shipped.
|
||||||
|
*/
|
||||||
|
export function getCardlessTrialCutover(): Date | null {
|
||||||
|
const raw = process.env.OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT?.trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const parsed = new Date(raw);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two equal-length windows either side of the cutover.
|
||||||
|
*
|
||||||
|
* The `after` window stops `COHORT_OBSERVATION_DAYS` short of now, because an
|
||||||
|
* account that signed up yesterday has not had its chance to convert yet and
|
||||||
|
* counting it would drag the new cohort's rate down for a month. The `before`
|
||||||
|
* window is then cut to the same length, ending at the cutover.
|
||||||
|
*/
|
||||||
|
export function cohortWindows(
|
||||||
|
cutover: Date,
|
||||||
|
now: Date,
|
||||||
|
observationDays: number = COHORT_OBSERVATION_DAYS
|
||||||
|
) {
|
||||||
|
const msPerDay = 24 * 60 * 60 * 1000;
|
||||||
|
const afterStart = cutover;
|
||||||
|
const afterEnd = new Date(now.getTime() - observationDays * msPerDay);
|
||||||
|
const spanMs = Math.max(0, afterEnd.getTime() - afterStart.getTime());
|
||||||
|
|
||||||
|
return {
|
||||||
|
afterStart,
|
||||||
|
afterEnd: new Date(afterStart.getTime() + spanMs),
|
||||||
|
beforeStart: new Date(cutover.getTime() - spanMs),
|
||||||
|
beforeEnd: cutover,
|
||||||
|
windowDays: Math.floor(spanMs / msPerDay),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CohortQueryRow {
|
||||||
|
cohort: string;
|
||||||
|
signups: number;
|
||||||
|
trials: number;
|
||||||
|
paid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card-first against cardless, on signup-to-paid rather than trial-to-paid.
|
||||||
|
*
|
||||||
|
* Trial-to-paid is the wrong ratio for this comparison and will mislead whoever
|
||||||
|
* reads it: handing out trials without a card multiplies the denominator, so the
|
||||||
|
* rate can halve while the number of paying customers goes up. Signups are the
|
||||||
|
* honest denominator because they are the one thing the change does not move.
|
||||||
|
*/
|
||||||
|
export async function getCohortComparison(
|
||||||
|
now: Date = new Date()
|
||||||
|
): Promise<CohortComparison | null> {
|
||||||
|
const cutover = getCardlessTrialCutover();
|
||||||
|
if (!cutover) return null;
|
||||||
|
|
||||||
|
const { afterStart, afterEnd, beforeStart, beforeEnd, windowDays } = cohortWindows(cutover, now);
|
||||||
|
const observationInterval = `${COHORT_OBSERVATION_DAYS} days`;
|
||||||
|
|
||||||
|
const rows = await db.$queryRaw<CohortQueryRow[]>`
|
||||||
|
SELECT CASE WHEN u."createdAt" >= ${cutover} THEN 'CARDLESS' ELSE 'CARD_FIRST' END AS cohort,
|
||||||
|
COUNT(*)::int AS signups,
|
||||||
|
COUNT(*) FILTER (WHERE t.started_at IS NOT NULL)::int AS trials,
|
||||||
|
COUNT(*) FILTER (WHERE p.paid_at IS NOT NULL)::int AS paid
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT MIN(e.occurred_at) AS started_at
|
||||||
|
FROM analytics_events e
|
||||||
|
WHERE e.user_id = u.id
|
||||||
|
AND e.name::text = 'TRIAL_STARTED'
|
||||||
|
AND e.occurred_at <= u."createdAt" + ${observationInterval}::interval
|
||||||
|
) t ON TRUE
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT MIN(e.occurred_at) AS paid_at
|
||||||
|
FROM analytics_events e
|
||||||
|
WHERE e.user_id = u.id
|
||||||
|
AND e.name::text = 'SUBSCRIPTION_STARTED'
|
||||||
|
AND e.occurred_at <= u."createdAt" + ${observationInterval}::interval
|
||||||
|
) p ON TRUE
|
||||||
|
WHERE (u."createdAt" >= ${beforeStart} AND u."createdAt" < ${beforeEnd})
|
||||||
|
OR (u."createdAt" >= ${afterStart} AND u."createdAt" < ${afterEnd})
|
||||||
|
GROUP BY 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byCohort = new Map(rows.map((row) => [row.cohort, row]));
|
||||||
|
const build = (cohort: TrialCohort, windowStart: Date, windowEnd: Date): CohortRow => {
|
||||||
|
const row = byCohort.get(cohort);
|
||||||
|
return {
|
||||||
|
cohort,
|
||||||
|
windowStart,
|
||||||
|
windowEnd,
|
||||||
|
signups: row?.signups ?? 0,
|
||||||
|
trials: row?.trials ?? 0,
|
||||||
|
paid: row?.paid ?? 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cutover,
|
||||||
|
observationDays: COHORT_OBSERVATION_DAYS,
|
||||||
|
windowDays,
|
||||||
|
rows: [build('CARD_FIRST', beforeStart, beforeEnd), build('CARDLESS', afterStart, afterEnd)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getScoreboard(options?: { weeks?: number }): Promise<Scoreboard> {
|
export async function getScoreboard(options?: { weeks?: number }): Promise<Scoreboard> {
|
||||||
const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52);
|
const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -204,7 +346,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
|
|||||||
const channelWindowStart = new Date(now);
|
const channelWindowStart = new Date(now);
|
||||||
channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS);
|
channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS);
|
||||||
|
|
||||||
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats] = await Promise.all([
|
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats, cohorts] = await Promise.all([
|
||||||
// COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing
|
// COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing
|
||||||
// view is deduped per visitor per day, so a visitor who came back on three
|
// view is deduped per visitor per day, so a visitor who came back on three
|
||||||
// days would otherwise be three weekly visitors. Rows with no anonymous id
|
// days would otherwise be three weekly visitors. Rows with no anonymous id
|
||||||
@@ -256,6 +398,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
|
|||||||
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
|
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
|
||||||
`,
|
`,
|
||||||
getCachedStripeStats(),
|
getCachedStripeStats(),
|
||||||
|
getCohortComparison(now),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const byWeek = new Map<number, WeeklyRow>();
|
const byWeek = new Map<number, WeeklyRow>();
|
||||||
@@ -337,5 +480,6 @@ export async function getScoreboard(options?: { weeks?: number }): Promise<Score
|
|||||||
currentActivePaid: stripeStats?.activeSubscribers ?? null,
|
currentActivePaid: stripeStats?.activeSubscribers ?? null,
|
||||||
currentMrrCents: stripeStats?.mrrCents ?? null,
|
currentMrrCents: stripeStats?.mrrCents ?? null,
|
||||||
currency: stripeStats?.currency ?? 'usd',
|
currency: stripeStats?.currency ?? 'usd',
|
||||||
|
cohorts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -6,7 +6,7 @@ import { PrismaAdapter } from '@auth/prisma-adapter';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
|
||||||
import { hasBillingAccess } from '@/lib/billing';
|
import { hasBillingAccess, startCardlessTrial } from '@/lib/billing';
|
||||||
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
||||||
import { isEmailVerificationEnabled } from '@/lib/email-verification';
|
import { isEmailVerificationEnabled } from '@/lib/email-verification';
|
||||||
|
|
||||||
@@ -163,6 +163,15 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
visitor: await readVisitorContextFromHeaders(),
|
visitor: await readVisitorContextFromHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// And the trial with it, for the same reason: the two places that grant it
|
||||||
|
// are the credentials signup route and the verification link, and a social
|
||||||
|
// signup goes through neither. Without this a Google account reaches a
|
||||||
|
// locked product and a checkout that no longer offers a trial, which is the
|
||||||
|
// opposite of what the signup page promised it. The address is already
|
||||||
|
// proven here: the signIn callback above turns away an OAuth profile that
|
||||||
|
// reports its email as unverified.
|
||||||
|
await startCardlessTrial(user.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+214
-13
@@ -5,6 +5,8 @@ import { db } from '@/lib/db';
|
|||||||
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
|
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
|
||||||
|
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||||
|
import { TRIAL_WORKSPACE_LIMIT } from '@/lib/trial-limits';
|
||||||
|
|
||||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
||||||
BillingSubscriptionStatus.ACTIVE,
|
BillingSubscriptionStatus.ACTIVE,
|
||||||
@@ -23,6 +25,16 @@ const RECOVERABLE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
|||||||
BillingSubscriptionStatus.INCOMPLETE,
|
BillingSubscriptionStatus.INCOMPLETE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Statuses that mean no payment on this subscription has ever gone through.
|
||||||
|
// Stripe stamps a current period on an `incomplete` subscription all the same,
|
||||||
|
// so a checkout whose first charge failed leaves `stripeCurrentPeriodEnd` a
|
||||||
|
// month into the future with nothing paid behind it. Every other status in the
|
||||||
|
// enum follows at least one successful charge, or has no period end at all.
|
||||||
|
const UNPAID_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
||||||
|
BillingSubscriptionStatus.INCOMPLETE,
|
||||||
|
BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
|
export const DEFAULT_TRIAL_PERIOD_DAYS = 7;
|
||||||
const STORAGE_CLEANUP_GRACE_DAYS = 15;
|
const STORAGE_CLEANUP_GRACE_DAYS = 15;
|
||||||
|
|
||||||
@@ -43,6 +55,22 @@ export function hasActiveTrial(trialEndsAt: Date | null | undefined, now: Date =
|
|||||||
return Boolean(trialEndsAt && trialEndsAt.getTime() > now.getTime());
|
return Boolean(trialEndsAt && trialEndsAt.getTime() > now.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trial end date to keep when a Stripe sync has none of its own.
|
||||||
|
*
|
||||||
|
* An unexpired trial is an entitlement the account already holds, so billing
|
||||||
|
* state may add access but must never take a trial back before it has run out.
|
||||||
|
* Without this, a trial user who starts a checkout and abandons the card step
|
||||||
|
* lands on an `incomplete` subscription carrying no `trial_end`, and the sync
|
||||||
|
* would write `trialEndsAt: null` over their remaining days and lock them out of
|
||||||
|
* a product they were still entitled to. Nothing can be farmed this way either:
|
||||||
|
* `billingTrialConsumedAt` is what makes the trial once-per-account, and it is
|
||||||
|
* never cleared.
|
||||||
|
*/
|
||||||
|
export function keepUnexpiredTrial(trialEndsAt: Date | null | undefined, now: Date = new Date()) {
|
||||||
|
return hasActiveTrial(trialEndsAt, now) ? (trialEndsAt ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function hasActiveSubscription(status: BillingSubscriptionStatus | null | undefined) {
|
export function hasActiveSubscription(status: BillingSubscriptionStatus | null | undefined) {
|
||||||
if (!status) return false;
|
if (!status) return false;
|
||||||
return ACTIVE_SUBSCRIPTION_STATUSES.has(status);
|
return ACTIVE_SUBSCRIPTION_STATUSES.has(status);
|
||||||
@@ -56,6 +84,41 @@ export function hasRecoverableSubscription(status: BillingSubscriptionStatus | n
|
|||||||
return RECOVERABLE_SUBSCRIPTION_STATUSES.has(status);
|
return RECOVERABLE_SUBSCRIPTION_STATUSES.has(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this account is a paying customer, as opposed to one that merely has
|
||||||
|
* access right now.
|
||||||
|
*
|
||||||
|
* The cardless trial makes these two different questions for the first time: a
|
||||||
|
* trial account passes `hasBillingAccess` with no card and no Stripe customer
|
||||||
|
* behind it. Every ceiling that exists to bound what an unpaid account can cost
|
||||||
|
* us (storage, upload size, workspace count) hangs off this, not off access.
|
||||||
|
* A legacy Stripe trial counts as paid because a card was handed over for it.
|
||||||
|
*/
|
||||||
|
export function isPaidTier(
|
||||||
|
subject: Pick<BillingAccessSubject, 'subscriptionStatus' | 'stripeCurrentPeriodEnd'>,
|
||||||
|
now: Date = new Date()
|
||||||
|
) {
|
||||||
|
if (!isStripeFeatureEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasActiveSubscription(subject.subscriptionStatus)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The period end alone is not proof of payment. Checked here and not in
|
||||||
|
// `hasBillingAccess`, which keeps granting access on a period end it did not
|
||||||
|
// question before: the cost of being wrong there is a customer locked out,
|
||||||
|
// while the cost of being wrong here is a free account holding 200 GB.
|
||||||
|
if (UNPAID_SUBSCRIPTION_STATUSES.has(subject.subscriptionStatus)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
subject.stripeCurrentPeriodEnd && subject.stripeCurrentPeriodEnd.getTime() > now.getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
|
export function hasBillingAccess(subject: BillingAccessSubject, now: Date = new Date()) {
|
||||||
if (!isStripeFeatureEnabled()) {
|
if (!isStripeFeatureEnabled()) {
|
||||||
return true;
|
return true;
|
||||||
@@ -195,12 +258,52 @@ export function getBillingStatusLabel(status: BillingSubscriptionStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants the cardless trial, once per account, and reports whether this call is
|
||||||
|
* the one that granted it.
|
||||||
|
*
|
||||||
|
* Called where the email address is proven rather than where the account is
|
||||||
|
* created: an unverifiable address gets no trial, which is the cheapest abuse
|
||||||
|
* control available and the reason the two writes below can stay this simple.
|
||||||
|
*
|
||||||
|
* `billingTrialConsumedAt` is written here rather than only by the Stripe sync.
|
||||||
|
* It is the once-per-account marker, so a re-issued verification link, a second
|
||||||
|
* device or a replayed request all land on the `WHERE` clause and change nothing.
|
||||||
|
*/
|
||||||
|
export async function startCardlessTrial(userId: string, now: Date = new Date()) {
|
||||||
|
// Without billing nothing is gated, so a trial would be a date nobody reads.
|
||||||
|
// Writing one anyway would consume the trial of a self-hosted instance that
|
||||||
|
// later switches billing on.
|
||||||
|
if (!isStripeFeatureEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count } = await db.user.updateMany({
|
||||||
|
where: { id: userId, trialEndsAt: null, billingTrialConsumedAt: null },
|
||||||
|
data: {
|
||||||
|
trialEndsAt: getDefaultTrialEndsAt(now),
|
||||||
|
billingTrialConsumedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordEvent({
|
||||||
|
name: 'TRIAL_STARTED',
|
||||||
|
dedupeKey: eventKey('TRIAL_STARTED', userId),
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStripeCheckoutState(userId: string) {
|
export async function getStripeCheckoutState(userId: string) {
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
subscriptionStatus: true,
|
subscriptionStatus: true,
|
||||||
billingTrialConsumedAt: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,7 +314,6 @@ export async function getStripeCheckoutState(userId: string) {
|
|||||||
return {
|
return {
|
||||||
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
|
hasActiveSubscription: hasActiveSubscription(user.subscriptionStatus),
|
||||||
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
||||||
isTrialEligible: !user.billingTrialConsumedAt,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,15 +370,22 @@ export async function getWorkspaceCreationEligibility(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const billingAccess = hasBillingAccess(user);
|
const billingAccess = hasBillingAccess(user);
|
||||||
|
const isPaid = isPaidTier(user);
|
||||||
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
|
const collaborationCount = invitedWorkspaceCount + projectOnlyCollaborationCount;
|
||||||
|
|
||||||
|
// A paying account creates as many workspaces as it wants. Everyone else gets
|
||||||
|
// one, which covers both the cardless trial and the pre-trial state where an
|
||||||
|
// account may set a workspace up before it can open it.
|
||||||
const canCreateWorkspace =
|
const canCreateWorkspace =
|
||||||
!isStripeFeatureEnabled() ||
|
!isStripeFeatureEnabled() ||
|
||||||
billingAccess ||
|
isPaid ||
|
||||||
(ownedWorkspaceCount === 0 && collaborationCount === 0);
|
((billingAccess || collaborationCount === 0) && ownedWorkspaceCount < TRIAL_WORKSPACE_LIMIT);
|
||||||
|
|
||||||
let reason: string | null = null;
|
let reason: string | null = null;
|
||||||
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
|
if (!canCreateWorkspace && isStripeFeatureEnabled()) {
|
||||||
if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
|
if (billingAccess && ownedWorkspaceCount >= TRIAL_WORKSPACE_LIMIT) {
|
||||||
|
reason = 'Your free trial includes one workspace. Subscribe to create more.';
|
||||||
|
} else if (collaborationCount > 0 && ownedWorkspaceCount === 0) {
|
||||||
reason =
|
reason =
|
||||||
'You are currently collaborating in someone else’s workspace or project. Start a subscription to create a workspace of your own.';
|
'You are currently collaborating in someone else’s workspace or project. Start a subscription to create a workspace of your own.';
|
||||||
} else {
|
} else {
|
||||||
@@ -297,7 +406,7 @@ export async function getWorkspaceCreationEligibility(userId: string) {
|
|||||||
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
hasRecoverableSubscription: hasRecoverableSubscription(user.subscriptionStatus),
|
||||||
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
|
hasActiveTrial: hasActiveTrial(user.trialEndsAt),
|
||||||
hasBillingAccess: billingAccess,
|
hasBillingAccess: billingAccess,
|
||||||
isTrialEligible: !user.billingTrialConsumedAt,
|
isPaid,
|
||||||
stripeCustomerId: user.stripeCustomerId,
|
stripeCustomerId: user.stripeCustomerId,
|
||||||
stripeSubscriptionId: user.stripeSubscriptionId,
|
stripeSubscriptionId: user.stripeSubscriptionId,
|
||||||
stripePriceId: user.stripePriceId,
|
stripePriceId: user.stripePriceId,
|
||||||
@@ -325,6 +434,74 @@ export async function getBillingOverview(userId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long before the trial runs out the countdown starts being shown. */
|
||||||
|
export const TRIAL_ENDING_NOTICE_DAYS = 3;
|
||||||
|
|
||||||
|
export interface TrialNotice {
|
||||||
|
/** `ending` while access is still live, `ended` once it has lapsed. */
|
||||||
|
kind: 'ending' | 'ended';
|
||||||
|
endsAt: Date;
|
||||||
|
/** When the cleanup job becomes eligible to delete this account's media. */
|
||||||
|
contentKeptUntil: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-line trial status worth interrupting somebody with, or null.
|
||||||
|
*
|
||||||
|
* Both halves of the deadline are in one place because the useful message is the
|
||||||
|
* pair: an account is told when the trial runs out and, separately, that running
|
||||||
|
* out is not the moment its work disappears. The gap between those two dates is
|
||||||
|
* the fifteen-day cleanup grace period, and until now nothing in the product said
|
||||||
|
* it out loud, which made the end of a trial read as a deletion notice.
|
||||||
|
*/
|
||||||
|
export async function getTrialNotice(
|
||||||
|
userId: string,
|
||||||
|
now: Date = new Date()
|
||||||
|
): Promise<TrialNotice | null> {
|
||||||
|
if (!isStripeFeatureEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
subscriptionStatus: true,
|
||||||
|
trialEndsAt: true,
|
||||||
|
stripeCurrentPeriodEnd: true,
|
||||||
|
billingAccessEndedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// A paying account has a billing period, not a trial, and gets told about it
|
||||||
|
// in settings rather than in a banner on every page.
|
||||||
|
if (!user || isPaidTier(user, now)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentKeptUntil = getStorageCleanupEligibleAt(user);
|
||||||
|
|
||||||
|
if (hasActiveTrial(user.trialEndsAt, now) && user.trialEndsAt) {
|
||||||
|
const daysLeft = (user.trialEndsAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000);
|
||||||
|
if (daysLeft > TRIAL_ENDING_NOTICE_DAYS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'ending', endsAt: user.trialEndsAt, contentKeptUntil };
|
||||||
|
}
|
||||||
|
|
||||||
|
const endsAt = getBillingAccessEndDate(user);
|
||||||
|
if (!endsAt || hasBillingAccess(user, now)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the cleanup date there is nothing left to reassure anybody about.
|
||||||
|
if (contentKeptUntil && contentKeptUntil.getTime() <= now.getTime()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'ended', endsAt, contentKeptUntil };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOrCreateStripeCustomerId(userId: string) {
|
export async function getOrCreateStripeCustomerId(userId: string) {
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -395,6 +572,8 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
billingTrialConsumedAt: true,
|
billingTrialConsumedAt: true,
|
||||||
|
// Read so a cardless trial that has not run out survives this sync.
|
||||||
|
trialEndsAt: true,
|
||||||
// Read for the funnel: the transition is what gets recorded, so the state
|
// Read for the funnel: the transition is what gets recorded, so the state
|
||||||
// being overwritten has to be captured before the update below.
|
// being overwritten has to be captured before the update below.
|
||||||
subscriptionStatus: true,
|
subscriptionStatus: true,
|
||||||
@@ -430,6 +609,11 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
|||||||
const effectiveCurrentPeriodEnd =
|
const effectiveCurrentPeriodEnd =
|
||||||
hasEntitledPrice && currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : null;
|
hasEntitledPrice && currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : null;
|
||||||
const effectiveTrialEnd = hasEntitledPrice && trialEnd ? new Date(trialEnd * 1000) : null;
|
const effectiveTrialEnd = hasEntitledPrice && trialEnd ? new Date(trialEnd * 1000) : null;
|
||||||
|
// Stripe grants no trials any more, so `effectiveTrialEnd` is null for every
|
||||||
|
// subscription created after the cardless trial shipped, and this fallback is
|
||||||
|
// what stops an abandoned or failed checkout from erasing the days the account
|
||||||
|
// still had. Legacy card-backed trials keep arriving through the branch above.
|
||||||
|
const preservedTrialEnd = effectiveTrialEnd ?? keepUnexpiredTrial(user.trialEndsAt);
|
||||||
const hasAccess =
|
const hasAccess =
|
||||||
hasEntitledPrice &&
|
hasEntitledPrice &&
|
||||||
(hasActiveSubscription(mappedStatus) ||
|
(hasActiveSubscription(mappedStatus) ||
|
||||||
@@ -444,14 +628,23 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
|||||||
stripeCancelAtPeriodEnd: cancelAtPeriodEnd,
|
stripeCancelAtPeriodEnd: cancelAtPeriodEnd,
|
||||||
stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
|
stripeCancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
|
||||||
subscriptionStatus: mappedStatus,
|
subscriptionStatus: mappedStatus,
|
||||||
trialEndsAt: effectiveTrialEnd,
|
trialEndsAt: preservedTrialEnd,
|
||||||
billingTrialConsumedAt:
|
billingTrialConsumedAt:
|
||||||
hasEntitledPrice && trialEnd
|
hasEntitledPrice && trialEnd
|
||||||
? (user.billingTrialConsumedAt ?? new Date())
|
? (user.billingTrialConsumedAt ?? new Date())
|
||||||
: user.billingTrialConsumedAt,
|
: user.billingTrialConsumedAt,
|
||||||
billingAccessEndedAt: hasAccess
|
// A live trial means access has not ended, whatever the subscription says.
|
||||||
|
// Stamping an end date here while the trial runs would date the storage
|
||||||
|
// cleanup from today and tell the user their work dies before their trial
|
||||||
|
// does. `hasActiveTrial`, not merely a non-null date: a legacy Stripe trial
|
||||||
|
// that has already elapsed is a reason to stamp the end date, not to skip it.
|
||||||
|
billingAccessEndedAt:
|
||||||
|
hasAccess || hasActiveTrial(preservedTrialEnd)
|
||||||
? null
|
? null
|
||||||
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
|
: getInactiveBillingAccessEndedAt(
|
||||||
|
subscription,
|
||||||
|
hasEntitledPrice ? currentPeriodEnd : null
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -466,7 +659,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
|||||||
after: {
|
after: {
|
||||||
status: mappedStatus,
|
status: mappedStatus,
|
||||||
cancelAtPeriodEnd,
|
cancelAtPeriodEnd,
|
||||||
trialEndsAt: effectiveTrialEnd,
|
trialEndsAt: preservedTrialEnd,
|
||||||
currentPeriodEnd: effectiveCurrentPeriodEnd,
|
currentPeriodEnd: effectiveCurrentPeriodEnd,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -554,6 +747,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
|||||||
stripeCancelAtPeriodEnd: true,
|
stripeCancelAtPeriodEnd: true,
|
||||||
stripeCurrentPeriodEnd: true,
|
stripeCurrentPeriodEnd: true,
|
||||||
billingTrialConsumedAt: true,
|
billingTrialConsumedAt: true,
|
||||||
|
trialEndsAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,17 +755,24 @@ export async function markSubscriptionCanceledByCustomerId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Losing the subscription does not retract a trial that has not run out. The
|
||||||
|
// account keeps the days it was given and lands back on the trial's own end
|
||||||
|
// date, which is also what the cancellation copy in settings promises.
|
||||||
|
const preservedTrialEnd = keepUnexpiredTrial(user.trialEndsAt);
|
||||||
|
|
||||||
const updated = await db.user.update({
|
const updated = await db.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||||
trialEndsAt: null,
|
trialEndsAt: preservedTrialEnd,
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
stripePriceId: null,
|
stripePriceId: null,
|
||||||
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
|
stripeCurrentPeriodEnd: options?.currentPeriodEnd ?? null,
|
||||||
stripeCancelAtPeriodEnd: false,
|
stripeCancelAtPeriodEnd: false,
|
||||||
stripeCancelAt: null,
|
stripeCancelAt: null,
|
||||||
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
|
billingAccessEndedAt: preservedTrialEnd
|
||||||
|
? null
|
||||||
|
: (options?.endedAt ?? options?.currentPeriodEnd ?? new Date()),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -590,7 +791,7 @@ export async function markSubscriptionCanceledByCustomerId(
|
|||||||
after: {
|
after: {
|
||||||
status: BillingSubscriptionStatus.CANCELED,
|
status: BillingSubscriptionStatus.CANCELED,
|
||||||
cancelAtPeriodEnd: false,
|
cancelAtPeriodEnd: false,
|
||||||
trialEndsAt: null,
|
trialEndsAt: preservedTrialEnd,
|
||||||
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
|
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,3 +26,73 @@ export function isValidEmailAddress(email: string): boolean {
|
|||||||
|
|
||||||
return labels.every((label) => label.length > 0 && label.length <= MAX_EMAIL_DOMAIN_LABEL_LENGTH);
|
return labels.every((label) => label.length > 0 && label.length <= MAX_EMAIL_DOMAIN_LABEL_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throwaway mailbox providers, refused at signup.
|
||||||
|
*
|
||||||
|
* The free trial is granted to any address somebody can read a link at, so a
|
||||||
|
* mailbox that costs nothing and expires in ten minutes is the cheapest way to
|
||||||
|
* take the trial repeatedly. This list is deliberately short and specific: it
|
||||||
|
* holds services whose entire purpose is a disposable inbox, and none of the
|
||||||
|
* forwarding or aliasing services (SimpleLogin, AnonAddy, Apple's Hide My Email,
|
||||||
|
* Fastmail masked addresses) that real paying customers use every day. A list
|
||||||
|
* that catches a genuine buyer costs far more than one that misses a scraper.
|
||||||
|
*/
|
||||||
|
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
||||||
|
'10minutemail.com',
|
||||||
|
'discard.email',
|
||||||
|
'dispostable.com',
|
||||||
|
'emailondeck.com',
|
||||||
|
'fakeinbox.com',
|
||||||
|
'getnada.com',
|
||||||
|
'grr.la',
|
||||||
|
'guerrillamail.com',
|
||||||
|
'guerrillamail.net',
|
||||||
|
'guerrillamail.org',
|
||||||
|
'harakirimail.com',
|
||||||
|
'inboxkitten.com',
|
||||||
|
'mailcatch.com',
|
||||||
|
'maildrop.cc',
|
||||||
|
'mailinator.com',
|
||||||
|
'mailnesia.com',
|
||||||
|
'mintemail.com',
|
||||||
|
'moakt.com',
|
||||||
|
'mohmal.com',
|
||||||
|
'nada.email',
|
||||||
|
'sharklasers.com',
|
||||||
|
'spam4.me',
|
||||||
|
'spamgourmet.com',
|
||||||
|
'temp-mail.org',
|
||||||
|
'tempinbox.com',
|
||||||
|
'tempmail.com',
|
||||||
|
'tempr.email',
|
||||||
|
'throwawaymail.com',
|
||||||
|
'tmpmail.org',
|
||||||
|
'trashmail.com',
|
||||||
|
'yopmail.com',
|
||||||
|
'yopmail.fr',
|
||||||
|
'yopmail.net',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the address belongs to a known disposable mailbox provider.
|
||||||
|
*
|
||||||
|
* Parent domains are checked too, because several of these hand out per-visit
|
||||||
|
* subdomains (`anything.mailinator.com`) that would otherwise walk straight past
|
||||||
|
* an exact-match lookup.
|
||||||
|
*/
|
||||||
|
export function isDisposableEmailDomain(email: string): boolean {
|
||||||
|
const atIndex = email.lastIndexOf('@');
|
||||||
|
if (atIndex < 0) return false;
|
||||||
|
|
||||||
|
const domain = normalizeEmail(email.slice(atIndex + 1));
|
||||||
|
const labels = domain.split('.');
|
||||||
|
|
||||||
|
for (let index = 0; index < labels.length - 1; index += 1) {
|
||||||
|
if (DISPOSABLE_EMAIL_DOMAINS.has(labels.slice(index).join('.'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
} from '@/lib/email-brand';
|
} from '@/lib/email-brand';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
import { isProductAnalyticsEnabled, isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
|
import { startCardlessTrial } from '@/lib/billing';
|
||||||
|
|
||||||
// Reduce window to 2 hours — shorter exposure in access logs and backups.
|
// Reduce window to 2 hours — shorter exposure in access logs and backups.
|
||||||
const TOKEN_EXPIRY_HOURS = 2;
|
const TOKEN_EXPIRY_HOURS = 2;
|
||||||
@@ -29,6 +30,28 @@ export function isEmailVerificationEnabled(): boolean {
|
|||||||
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASSWORD);
|
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let warnedAboutUnverifiedTrials = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Says so, once, when an instance is handing out free trials to addresses nobody
|
||||||
|
* has proved.
|
||||||
|
*
|
||||||
|
* Billing switched on means the trial is worth something, and no SMTP means there
|
||||||
|
* is no verification step to hang it on, so every signup form submission mints
|
||||||
|
* seven days of storage. That combination is a deployment mistake rather than a
|
||||||
|
* choice, and it is invisible until the storage bill arrives.
|
||||||
|
*/
|
||||||
|
export function warnIfTrialsSkipVerification(): void {
|
||||||
|
if (warnedAboutUnverifiedTrials) return;
|
||||||
|
if (isEmailVerificationEnabled() || !isStripeFeatureEnabled()) return;
|
||||||
|
|
||||||
|
warnedAboutUnverifiedTrials = true;
|
||||||
|
logError(
|
||||||
|
'Free trials are being granted without email verification because SMTP is not configured while billing is enabled. Configure SMTP_HOST, SMTP_USER and SMTP_PASSWORD.',
|
||||||
|
new Error('Unverified trial signups')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a secure random verification token, persist only its SHA-256 digest,
|
* Generate a secure random verification token, persist only its SHA-256 digest,
|
||||||
* and return the raw token (sent to the user via email).
|
* and return the raw token (sent to the user via email).
|
||||||
@@ -79,21 +102,25 @@ export async function consumeVerificationToken(token: string): Promise<string |
|
|||||||
// Return null so a replayed/stale token never produces a misleading success redirect.
|
// Return null so a replayed/stale token never produces a misleading success redirect.
|
||||||
if (user.count === 0) return null;
|
if (user.count === 0) return null;
|
||||||
|
|
||||||
// Behind the flag so the extra lookup does not happen at all on a deployment
|
// This is where the free trial begins: a proven address, before any card and
|
||||||
// that is not measuring. count > 0 above already means this is the one call
|
// before Stripe is involved at all. count > 0 above means this call is the one
|
||||||
// that flipped the account, so a replayed link cannot reach here.
|
// that flipped the account, so a replayed link cannot reach here, and
|
||||||
if (isProductAnalyticsEnabled()) {
|
// `startCardlessTrial` refuses a second trial regardless.
|
||||||
const verified = await db.user.findUnique({
|
const verified = await db.user.findUnique({
|
||||||
where: { email: record.identifier },
|
where: { email: record.identifier },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (verified) {
|
if (verified) {
|
||||||
|
if (isProductAnalyticsEnabled()) {
|
||||||
await recordEvent({
|
await recordEvent({
|
||||||
name: 'EMAIL_VERIFIED',
|
name: 'EMAIL_VERIFIED',
|
||||||
dedupeKey: eventKey('EMAIL_VERIFIED', verified.id),
|
dedupeKey: eventKey('EMAIL_VERIFIED', verified.id),
|
||||||
userId: verified.id,
|
userId: verified.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await startCardlessTrial(verified.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return record.identifier;
|
return record.identifier;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { competitorProfiles, openFrameProfile } from '@/lib/marketing/comparison
|
|||||||
|
|
||||||
const commonOpenFrameWins = [
|
const commonOpenFrameWins = [
|
||||||
'$10/month flat hosted pricing — no per-member or per-guest fees',
|
'$10/month flat hosted pricing — no per-member or per-guest fees',
|
||||||
'7-day free trial, then unlimited collaborators on one plan',
|
'7-day free trial with no credit card, then unlimited collaborators on one plan',
|
||||||
'Self-host for free with Docker when you need full data control',
|
'Self-host for free with Docker when you need full data control',
|
||||||
'Voice notes and drawn annotations on the timeline',
|
'Voice notes and drawn annotations on the timeline',
|
||||||
'Formal approval requests with per-reviewer status',
|
'Formal approval requests with per-reviewer status',
|
||||||
@@ -148,7 +148,7 @@ function competitorPricingRows(competitorId: string): PricingRow[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Trial',
|
label: 'Trial',
|
||||||
openframe: '7-day free trial on hosted',
|
openframe: '7-day free trial on hosted, no card required',
|
||||||
competitor: profile.pricingNotes[0] ?? 'See vendor site',
|
competitor: profile.pricingNotes[0] ?? 'See vendor site',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -402,7 +402,8 @@ export const comparisonPages: ComparisonPageDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I start free?',
|
question: 'Can I start free?',
|
||||||
answer: 'Yes. Use the 7-day hosted trial or self-host for free with Docker.',
|
answer:
|
||||||
|
'Yes. The 7-day hosted trial does not ask for a card, and self-hosting with Docker is free.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Is OpenFrame only for video?',
|
question: 'Is OpenFrame only for video?',
|
||||||
@@ -662,7 +663,7 @@ export const comparisonPages: ComparisonPageDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Trial',
|
label: 'Trial',
|
||||||
openframe: '7-day free trial on hosted',
|
openframe: '7-day free trial on hosted, no card required',
|
||||||
competitor: 'Free plan — no card required',
|
competitor: 'Free plan — no card required',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ export const competitorProfiles: Record<string, CompetitorProfile> = {
|
|||||||
export const openFrameProfile = {
|
export const openFrameProfile = {
|
||||||
name: 'OpenFrame',
|
name: 'OpenFrame',
|
||||||
tagline: 'Fair-source video review and approval with hosted and self-hosted options.',
|
tagline: 'Fair-source video review and approval with hosted and self-hosted options.',
|
||||||
pricingSummary: 'Self-hosted free · Hosted $10/mo after 7-day trial',
|
pricingSummary: 'Self-hosted free · Hosted $10/mo after a 7-day no-card trial',
|
||||||
trial: '7-day free trial on hosted plan',
|
trial: '7-day free trial on hosted plan',
|
||||||
strengths: [
|
strengths: [
|
||||||
'Timestamped text, voice, image, and drawn annotations',
|
'Timestamped text, voice, image, and drawn annotations',
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export function buildComparisonJsonLd({
|
|||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: '10',
|
price: '10',
|
||||||
priceCurrency: 'USD',
|
priceCurrency: 'USD',
|
||||||
description: '7-day free trial, then $10/month hosted plan. Self-hosted option is free.',
|
description:
|
||||||
|
'7-day free trial with no credit card, then $10/month hosted plan. Self-hosted option is free.',
|
||||||
},
|
},
|
||||||
url: seoConfig.url,
|
url: seoConfig.url,
|
||||||
},
|
},
|
||||||
|
|||||||
+36
-8
@@ -3,10 +3,29 @@ import { db } from '@/lib/db';
|
|||||||
import { apiErrors } from '@/lib/api-response';
|
import { apiErrors } from '@/lib/api-response';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
||||||
|
import { isPaidTier } from '@/lib/billing';
|
||||||
|
import { getStorageLimitBytes } from '@/lib/trial-limits';
|
||||||
|
|
||||||
// 200 GB expressed in bytes
|
// 200 GB expressed in bytes
|
||||||
export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ceiling this particular account is held to.
|
||||||
|
*
|
||||||
|
* A cardless trial gets a much smaller one: it is the only thing standing between
|
||||||
|
* a throwaway signup and 200 GB of our storage. Reads the two billing columns
|
||||||
|
* directly rather than taking a flag from the caller, so no upload route can
|
||||||
|
* forget to pass it.
|
||||||
|
*/
|
||||||
|
async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
||||||
const RESERVATION_TTL_MS = 30 * 60 * 1000;
|
const RESERVATION_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
@@ -61,8 +80,10 @@ export async function getUserStorageInfo(userId: string): Promise<{
|
|||||||
limitBytes: bigint;
|
limitBytes: bigint;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}> {
|
}> {
|
||||||
const usedBytes = await getUserTotalStorageBytes(userId);
|
const [usedBytes, limitBytes] = await Promise.all([
|
||||||
const limitBytes = PLAN_STORAGE_LIMIT_BYTES;
|
getUserTotalStorageBytes(userId),
|
||||||
|
getStorageLimitForUser(userId),
|
||||||
|
]);
|
||||||
const percentage =
|
const percentage =
|
||||||
limitBytes > BigInt(0)
|
limitBytes > BigInt(0)
|
||||||
? Math.min(100, Number((usedBytes * BigInt(10000)) / limitBytes) / 100)
|
? Math.min(100, Number((usedBytes * BigInt(10000)) / limitBytes) / 100)
|
||||||
@@ -88,9 +109,12 @@ export async function enforceStorageQuota(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usedBytes = await getUserTotalStorageBytes(userId);
|
const [usedBytes, limitBytes] = await Promise.all([
|
||||||
|
getUserTotalStorageBytes(userId),
|
||||||
|
getStorageLimitForUser(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
if (usedBytes + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
|
if (usedBytes + incomingSizeBytes >= limitBytes) {
|
||||||
return apiErrors.storageExceeded() as NextResponse;
|
return apiErrors.storageExceeded() as NextResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +145,13 @@ export async function reserveStorageQuota(
|
|||||||
|
|
||||||
const expiresAt = new Date(Date.now() + reservationTtlMs);
|
const expiresAt = new Date(Date.now() + reservationTtlMs);
|
||||||
|
|
||||||
// Fetch Bunny storage BEFORE entering the transaction to avoid holding the
|
// Fetch Bunny storage and the account's ceiling BEFORE entering the transaction,
|
||||||
// advisory lock during a potentially slow/failing HTTP call on cache miss.
|
// to avoid holding the advisory lock during a potentially slow/failing HTTP call
|
||||||
const bunnyData = await getCachedUserBunnyStorage();
|
// on cache miss or an extra round trip to Postgres.
|
||||||
|
const [bunnyData, limitBytes] = await Promise.all([
|
||||||
|
getCachedUserBunnyStorage(),
|
||||||
|
getStorageLimitForUser(userId),
|
||||||
|
]);
|
||||||
const bunnyBytes = BigInt(bunnyData[userId] ?? 0);
|
const bunnyBytes = BigInt(bunnyData[userId] ?? 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -166,7 +194,7 @@ export async function reserveStorageQuota(
|
|||||||
const reservedBytes = resRow?.total ?? BigInt(0);
|
const reservedBytes = resRow?.total ?? BigInt(0);
|
||||||
|
|
||||||
const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
|
const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
|
||||||
if (totalUsed + incomingSizeBytes >= PLAN_STORAGE_LIMIT_BYTES) {
|
if (totalUsed + incomingSizeBytes >= limitBytes) {
|
||||||
throw new QuotaExceededError();
|
throw new QuotaExceededError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// What a cardless trial is allowed to consume.
|
||||||
|
//
|
||||||
|
// The trial exists to let somebody run one real review cycle before paying: upload
|
||||||
|
// a cut, share it, collect feedback, upload the revision. Everything that costs us
|
||||||
|
// nothing (YouTube and Vimeo embeds, share links, guests, comments, approvals) is
|
||||||
|
// therefore unlimited, and the caps sit only on the two things that do cost money
|
||||||
|
// or invite abuse: how much we store, and how many workspaces one unpaid account
|
||||||
|
// can hold open.
|
||||||
|
//
|
||||||
|
// These take a plain `isPaid` boolean rather than a user row so this module stays
|
||||||
|
// free of imports from `lib/billing.ts`, which imports the limits back.
|
||||||
|
|
||||||
|
/** One workspace, so an unpaid account cannot park a whole agency here. */
|
||||||
|
export const TRIAL_WORKSPACE_LIMIT = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One project at a time. There is no archive flag on Project, so "active" means
|
||||||
|
* "exists": deleting a project frees the slot.
|
||||||
|
*/
|
||||||
|
export const TRIAL_PROJECT_LIMIT = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3 GiB of direct uploads. Enough for a first cut plus two revisions at a real
|
||||||
|
* bitrate, small enough that a farm of throwaway accounts is not worth running.
|
||||||
|
* Anyone who hits it can still work through YouTube imports, which cost nothing.
|
||||||
|
*/
|
||||||
|
export const TRIAL_STORAGE_LIMIT_BYTES = BigInt(3) * BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||||
|
|
||||||
|
// There is deliberately no separate per-file ceiling for trials. The default
|
||||||
|
// per-file limit is 5 GiB and the trial's total is 3 GiB, so the quota check
|
||||||
|
// already refuses anything bigger, and a second limit would only add a second
|
||||||
|
// way to be told no.
|
||||||
|
|
||||||
|
export function getStorageLimitBytes(isPaid: boolean, planLimitBytes: bigint): bigint {
|
||||||
|
if (isPaid) return planLimitBytes;
|
||||||
|
return planLimitBytes < TRIAL_STORAGE_LIMIT_BYTES ? planLimitBytes : TRIAL_STORAGE_LIMIT_BYTES;
|
||||||
|
}
|
||||||
@@ -8,7 +8,11 @@
|
|||||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||||
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
|
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AT_RISK_SILENT_DAYS, getScoreboard } from '@/lib/analytics/scoreboard';
|
import {
|
||||||
|
AT_RISK_SILENT_DAYS,
|
||||||
|
getCohortComparison,
|
||||||
|
getScoreboard,
|
||||||
|
} from '@/lib/analytics/scoreboard';
|
||||||
import { createUser } from '../factories';
|
import { createUser } from '../factories';
|
||||||
|
|
||||||
function daysAgo(days: number): Date {
|
function daysAgo(days: number): Date {
|
||||||
@@ -179,3 +183,91 @@ describe('getScoreboard', () => {
|
|||||||
expect(busyRow?.valueEvents30).toBe(2);
|
expect(busyRow?.valueEvents30).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The cohort comparison is another block of raw SQL, and the part most easily
|
||||||
|
// got wrong is the observation window: a conversion that arrives two months
|
||||||
|
// after signup belongs to neither cohort's score.
|
||||||
|
describe('getCohortComparison', () => {
|
||||||
|
const CUTOVER = '2026-03-01T00:00:00.000Z';
|
||||||
|
const NOW = new Date('2026-05-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
async function seedAccount(params: { createdAt: string; trialAt?: string; paidAt?: string }) {
|
||||||
|
const user = await createUser();
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { createdAt: new Date(params.createdAt) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (params.trialAt) {
|
||||||
|
await seedEvent({
|
||||||
|
name: 'TRIAL_STARTED',
|
||||||
|
occurredAt: new Date(params.trialAt),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (params.paidAt) {
|
||||||
|
await seedEvent({
|
||||||
|
name: 'SUBSCRIPTION_STARTED',
|
||||||
|
occurredAt: new Date(params.paidAt),
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is null on a deployment that never named a switchover date', async () => {
|
||||||
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
|
||||||
|
|
||||||
|
expect(await getCohortComparison(NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits accounts by the cutover and scores each within its 30 days', async () => {
|
||||||
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
|
||||||
|
|
||||||
|
// Card first: one converted inside the window, one long after it.
|
||||||
|
await seedAccount({
|
||||||
|
createdAt: '2026-02-10T00:00:00.000Z',
|
||||||
|
paidAt: '2026-02-20T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
await seedAccount({
|
||||||
|
createdAt: '2026-02-10T00:00:00.000Z',
|
||||||
|
paidAt: '2026-03-25T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
// Cardless: both took the trial, one paid for it.
|
||||||
|
await seedAccount({
|
||||||
|
createdAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
trialAt: '2026-03-10T00:00:00.000Z',
|
||||||
|
paidAt: '2026-03-20T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
await seedAccount({
|
||||||
|
createdAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
trialAt: '2026-03-15T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
// Older than the matched window, and too new to have been observed yet.
|
||||||
|
await seedAccount({
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
paidAt: '2026-01-05T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
await seedAccount({
|
||||||
|
createdAt: '2026-04-15T00:00:00.000Z',
|
||||||
|
paidAt: '2026-04-16T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const comparison = await getCohortComparison(NOW);
|
||||||
|
|
||||||
|
expect(comparison?.rows).toEqual([
|
||||||
|
expect.objectContaining({ cohort: 'CARD_FIRST', signups: 2, trials: 0, paid: 1 }),
|
||||||
|
expect.objectContaining({ cohort: 'CARDLESS', signups: 2, trials: 2, paid: 1 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports both cohorts as empty rows rather than omitting them', async () => {
|
||||||
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', CUTOVER);
|
||||||
|
|
||||||
|
const comparison = await getCohortComparison(NOW);
|
||||||
|
|
||||||
|
expect(comparison?.rows.map((row) => row.cohort)).toEqual(['CARD_FIRST', 'CARDLESS']);
|
||||||
|
expect(comparison?.rows.every((row) => row.signups === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,6 +96,45 @@ describe('consumeVerificationToken', () => {
|
|||||||
expect(await db.verificationToken.count()).toBe(0);
|
expect(await db.verificationToken.count()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verification is where the free trial begins, which is what makes a proven
|
||||||
|
// address the price of admission rather than a formality.
|
||||||
|
it('starts the seven day trial on the account it verifies', async () => {
|
||||||
|
const user = await createUser({
|
||||||
|
email: '[email protected]',
|
||||||
|
emailVerified: null,
|
||||||
|
trialEndsAt: null,
|
||||||
|
billingTrialConsumedAt: null,
|
||||||
|
});
|
||||||
|
const token = await createVerificationToken('[email protected]');
|
||||||
|
|
||||||
|
await consumeVerificationToken(token);
|
||||||
|
|
||||||
|
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||||
|
expect(verified.billingTrialConsumedAt).toBeInstanceOf(Date);
|
||||||
|
const days =
|
||||||
|
(verified.trialEndsAt!.getTime() - verified.billingTrialConsumedAt!.getTime()) /
|
||||||
|
(24 * 60 * 60 * 1000);
|
||||||
|
expect(days).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not hand a second trial to an account that already had one', async () => {
|
||||||
|
const consumedAt = new Date('2026-01-01T00:00:00.000Z');
|
||||||
|
const trialEndsAt = new Date('2026-01-08T00:00:00.000Z');
|
||||||
|
const user = await createUser({
|
||||||
|
email: '[email protected]',
|
||||||
|
emailVerified: null,
|
||||||
|
trialEndsAt,
|
||||||
|
billingTrialConsumedAt: consumedAt,
|
||||||
|
});
|
||||||
|
const token = await createVerificationToken('[email protected]');
|
||||||
|
|
||||||
|
await consumeVerificationToken(token);
|
||||||
|
|
||||||
|
const verified = await db.user.findUniqueOrThrow({ where: { id: user.id } });
|
||||||
|
expect(verified.trialEndsAt).toEqual(trialEndsAt);
|
||||||
|
expect(verified.billingTrialConsumedAt).toEqual(consumedAt);
|
||||||
|
});
|
||||||
|
|
||||||
it('refuses a replayed token and keeps the original verification timestamp', async () => {
|
it('refuses a replayed token and keeps the original verification timestamp', async () => {
|
||||||
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
||||||
const token = await createVerificationToken('[email protected]');
|
const token = await createVerificationToken('[email protected]');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
addWorkspaceMember,
|
addWorkspaceMember,
|
||||||
createExpiredUser,
|
createExpiredUser,
|
||||||
createProject,
|
createProject,
|
||||||
|
createSubscribedUser,
|
||||||
createUser,
|
createUser,
|
||||||
createVideo,
|
createVideo,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
@@ -338,8 +339,10 @@ describe('POST /api/projects', () => {
|
|||||||
expect(stored.allowDownloads).toBe(false);
|
expect(stored.allowDownloads).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subscribed rather than the default trial user: three projects is past the
|
||||||
|
// trial's ceiling, and this test is about slugs, not about billing.
|
||||||
it('gives two projects with the same name distinct slugs', async () => {
|
it('gives two projects with the same name distinct slugs', async () => {
|
||||||
const owner = await createUser();
|
const owner = await createSubscribedUser();
|
||||||
const workspace = await createWorkspace({ ownerId: owner.id });
|
const workspace = await createWorkspace({ ownerId: owner.id });
|
||||||
signedInAs(owner);
|
signedInAs(owner);
|
||||||
|
|
||||||
@@ -355,6 +358,55 @@ describe('POST /api/projects', () => {
|
|||||||
expect(slugs.sort()).toEqual(['same-name', 'same-name-1', 'same-name-2']);
|
expect(slugs.sort()).toEqual(['same-name', 'same-name-1', 'same-name-2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses a second project while the owner is on a free trial', async () => {
|
||||||
|
const owner = await createUser();
|
||||||
|
const workspace = await createWorkspace({ ownerId: owner.id });
|
||||||
|
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
|
||||||
|
signedInAs(owner);
|
||||||
|
|
||||||
|
const response = await callRoute(
|
||||||
|
createProjectRoute,
|
||||||
|
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await db.project.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a paying owner past that ceiling', async () => {
|
||||||
|
const owner = await createSubscribedUser();
|
||||||
|
const workspace = await createWorkspace({ ownerId: owner.id });
|
||||||
|
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
|
||||||
|
signedInAs(owner);
|
||||||
|
|
||||||
|
const response = await callRoute(
|
||||||
|
createProjectRoute,
|
||||||
|
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(await db.project.count()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The ceiling belongs to the account being billed, so a workspace admin cannot
|
||||||
|
// spend somebody else's trial allowance either.
|
||||||
|
it('counts the ceiling against the workspace owner, not the caller', async () => {
|
||||||
|
const owner = await createUser();
|
||||||
|
const workspace = await createWorkspace({ ownerId: owner.id });
|
||||||
|
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
|
||||||
|
const admin = await createUser();
|
||||||
|
await addWorkspaceMember({ workspaceId: workspace.id, userId: admin.id, role: 'ADMIN' });
|
||||||
|
signedInAs(admin);
|
||||||
|
|
||||||
|
const response = await callRoute(
|
||||||
|
createProjectRoute,
|
||||||
|
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await db.project.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('honours an explicit PUBLIC visibility', async () => {
|
it('honours an explicit PUBLIC visibility', async () => {
|
||||||
const owner = await createUser();
|
const owner = await createUser();
|
||||||
const workspace = await createWorkspace({ ownerId: owner.id });
|
const workspace = await createWorkspace({ ownerId: owner.id });
|
||||||
|
|||||||
@@ -351,6 +351,62 @@ describe('POST /api/auth/register', () => {
|
|||||||
expect(sentMail()).toEqual([]);
|
expect(sentMail()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses a disposable mailbox with 400 and stores nothing', async () => {
|
||||||
|
const response = await post({
|
||||||
|
name: 'Throwaway Person',
|
||||||
|
email: '[email protected]',
|
||||||
|
password: PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await db.user.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The block exists to stop trial farming, which is a self-signup problem. An
|
||||||
|
// invited collaborator was vouched for by a paying customer, so refusing their
|
||||||
|
// address would break that customer's review instead.
|
||||||
|
it('accepts a disposable mailbox when an invitation vouches for it', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
const invitation = await createInvitation({
|
||||||
|
invitedById: scenario.owner.id,
|
||||||
|
scope: 'PROJECT',
|
||||||
|
projectId: scenario.project.id,
|
||||||
|
email: '[email protected]',
|
||||||
|
role: 'COMMENTATOR',
|
||||||
|
});
|
||||||
|
signedOut();
|
||||||
|
|
||||||
|
const response = await callRoute(
|
||||||
|
register,
|
||||||
|
registerRequest({
|
||||||
|
name: 'Invited Guest',
|
||||||
|
email: '[email protected]',
|
||||||
|
password: PASSWORD,
|
||||||
|
invitationToken: invitation.token,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(await db.user.count()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SMTP is configured in .env.test, so registration alone proves nothing about
|
||||||
|
// the address and grants no trial. Verification is what starts the clock.
|
||||||
|
it('leaves the trial unstarted until the address has been verified', async () => {
|
||||||
|
const response = await post({
|
||||||
|
name: 'Unverified Person',
|
||||||
|
email: '[email protected]',
|
||||||
|
password: PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
const created = await db.user.findUniqueOrThrow({
|
||||||
|
where: { email: '[email protected]' },
|
||||||
|
});
|
||||||
|
expect(created.trialEndsAt).toBeNull();
|
||||||
|
expect(created.billingTrialConsumedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('reports the rate limit budget on a successful registration', async () => {
|
it('reports the rate limit budget on a successful registration', async () => {
|
||||||
const response = await post({
|
const response = await post({
|
||||||
name: 'Rate Limited',
|
name: 'Rate Limited',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { signedInAs, signedOut } from '../helpers/session';
|
|||||||
import {
|
import {
|
||||||
createExpiredUser,
|
createExpiredUser,
|
||||||
createProject,
|
createProject,
|
||||||
|
createSubscribedUser,
|
||||||
createUploadReservation,
|
createUploadReservation,
|
||||||
createUser,
|
createUser,
|
||||||
createVersion,
|
createVersion,
|
||||||
@@ -59,9 +60,47 @@ describe('PLAN_STORAGE_LIMIT_BYTES', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Everything else in this file bills a subscribed account, because the plan
|
||||||
|
// ceiling and the advisory lock are what those tests are about. This block is
|
||||||
|
// the other half: the same code paths, held to the trial ceiling instead.
|
||||||
|
describe('the trial ceiling', () => {
|
||||||
|
it('reports the trial limit rather than the plan limit for a trial account', async () => {
|
||||||
|
const user = await createUser();
|
||||||
|
|
||||||
|
expect((await getUserStorageInfo(user.id)).limitBytes).toBe(BigInt(3) * GIB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an upload that a paying account of the same size would be allowed', async () => {
|
||||||
|
const trialUser = await createUser();
|
||||||
|
const paidUser = await createSubscribedUser();
|
||||||
|
const fourGiB = BigInt(4) * GIB;
|
||||||
|
|
||||||
|
expect((await enforceStorageQuota(trialUser.id, fourGiB))?.status).toBe(507);
|
||||||
|
expect(await enforceStorageQuota(paidUser.id, fourGiB)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a reservation to the trial ceiling too, not only the plain check', async () => {
|
||||||
|
const user = await createUser();
|
||||||
|
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(2) * GIB });
|
||||||
|
|
||||||
|
const result = await reserveStorageQuota(user.id, BigInt(2) * GIB);
|
||||||
|
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
expect((result as { error: Response }).error.status).toBe(507);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets a trial account upload inside its own ceiling', async () => {
|
||||||
|
const user = await createUser();
|
||||||
|
|
||||||
|
const result = await reserveStorageQuota(user.id, BigInt(1) * GIB);
|
||||||
|
|
||||||
|
expect('reservationId' in result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getUserTotalStorageBytes', () => {
|
describe('getUserTotalStorageBytes', () => {
|
||||||
it('is zero for a user with nothing stored', async () => {
|
it('is zero for a user with nothing stored', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
|
|
||||||
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(0));
|
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(0));
|
||||||
});
|
});
|
||||||
@@ -93,7 +132,7 @@ describe('getUserTotalStorageBytes', () => {
|
|||||||
|
|
||||||
it('ignores assets billed to somebody else and non-R2 providers', async () => {
|
it('ignores assets billed to somebody else and non-R2 providers', async () => {
|
||||||
const scenario = await seedProject();
|
const scenario = await seedProject();
|
||||||
const other = await createUser();
|
const other = await createSubscribedUser();
|
||||||
const video = await createVideo({ projectId: scenario.project.id });
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
await createVideoAsset({
|
await createVideoAsset({
|
||||||
videoId: video.id,
|
videoId: video.id,
|
||||||
@@ -121,9 +160,9 @@ describe('getUserTotalStorageBytes', () => {
|
|||||||
// Versions are billed through the workspace owner, not through the project
|
// Versions are billed through the workspace owner, not through the project
|
||||||
// owner or the uploader, which is what the join in the raw SQL encodes.
|
// owner or the uploader, which is what the join in the raw SQL encodes.
|
||||||
it('sums r2 video versions through the workspace owner', async () => {
|
it('sums r2 video versions through the workspace owner', async () => {
|
||||||
const workspaceOwner = await createUser();
|
const workspaceOwner = await createSubscribedUser();
|
||||||
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
|
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
|
||||||
const projectOwner = await createUser();
|
const projectOwner = await createSubscribedUser();
|
||||||
const project = await createProject({
|
const project = await createProject({
|
||||||
ownerId: projectOwner.id,
|
ownerId: projectOwner.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
@@ -146,7 +185,7 @@ describe('getUserTotalStorageBytes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('counts active reservations and ignores expired ones', async () => {
|
it('counts active reservations and ignores expired ones', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1000) });
|
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1000) });
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
@@ -158,7 +197,7 @@ describe('getUserTotalStorageBytes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('adds the Bunny Stream bytes reported for the user', async () => {
|
it('adds the Bunny Stream bytes reported for the user', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
bunnyStorage({ [user.id]: 12_345 });
|
bunnyStorage({ [user.id]: 12_345 });
|
||||||
|
|
||||||
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(12_345));
|
expect(await getUserTotalStorageBytes(user.id)).toBe(BigInt(12_345));
|
||||||
@@ -167,7 +206,7 @@ describe('getUserTotalStorageBytes', () => {
|
|||||||
|
|
||||||
describe('getUserStorageInfo', () => {
|
describe('getUserStorageInfo', () => {
|
||||||
it('reports the percentage to two decimal places', async () => {
|
it('reports the percentage to two decimal places', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: BigInt(50) * GIB,
|
sizeBytes: BigInt(50) * GIB,
|
||||||
@@ -181,7 +220,7 @@ describe('getUserStorageInfo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clamps the percentage at 100 when usage exceeds the limit', async () => {
|
it('clamps the percentage at 100 when usage exceeds the limit', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES * BigInt(3),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES * BigInt(3),
|
||||||
@@ -193,7 +232,7 @@ describe('getUserStorageInfo', () => {
|
|||||||
|
|
||||||
describe('enforceStorageQuota', () => {
|
describe('enforceStorageQuota', () => {
|
||||||
it('allows an upload that stays under the limit', async () => {
|
it('allows an upload that stays under the limit', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
|
|
||||||
expect(await enforceStorageQuota(user.id, BigInt(1024))).toBeNull();
|
expect(await enforceStorageQuota(user.id, BigInt(1024))).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -201,7 +240,7 @@ describe('enforceStorageQuota', () => {
|
|||||||
// The route uses `>=`, so a user sitting exactly on the limit is blocked
|
// The route uses `>=`, so a user sitting exactly on the limit is blocked
|
||||||
// rather than allowed one more byte.
|
// rather than allowed one more byte.
|
||||||
it('rejects an upload that lands exactly on the limit', async () => {
|
it('rejects an upload that lands exactly on the limit', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
@@ -213,7 +252,7 @@ describe('enforceStorageQuota', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('allows an upload one byte short of the limit', async () => {
|
it('allows an upload one byte short of the limit', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
@@ -224,7 +263,7 @@ describe('enforceStorageQuota', () => {
|
|||||||
|
|
||||||
it('skips the check entirely when Stripe is disabled', async () => {
|
it('skips the check entirely when Stripe is disabled', async () => {
|
||||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES,
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES,
|
||||||
@@ -236,7 +275,7 @@ describe('enforceStorageQuota', () => {
|
|||||||
|
|
||||||
describe('reserveStorageQuota', () => {
|
describe('reserveStorageQuota', () => {
|
||||||
it('writes a reservation row billed to the user with the requested size', async () => {
|
it('writes a reservation row billed to the user with the requested size', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
||||||
|
|
||||||
@@ -250,7 +289,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
|
|
||||||
it('returns a null reservation id and writes nothing when Stripe is disabled', async () => {
|
it('returns a null reservation id and writes nothing when Stripe is disabled', async () => {
|
||||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
const result = await reserveStorageQuota(user.id, BigInt(4096));
|
||||||
|
|
||||||
@@ -259,7 +298,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('refuses a reservation that would cross the limit and writes no row', async () => {
|
it('refuses a reservation that would cross the limit and writes no row', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
@@ -289,7 +328,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('counts Bunny Stream bytes against the reservation', async () => {
|
it('counts Bunny Stream bytes against the reservation', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
|
bunnyStorage({ [user.id]: Number(PLAN_STORAGE_LIMIT_BYTES - BigInt(1024)) });
|
||||||
|
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
const result = await reserveStorageQuota(user.id, BigInt(2048));
|
||||||
@@ -299,7 +338,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores an expired reservation when computing headroom', async () => {
|
it('ignores an expired reservation when computing headroom', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
@@ -312,8 +351,8 @@ describe('reserveStorageQuota', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not let one user reservations reduce another user headroom', async () => {
|
it('does not let one user reservations reduce another user headroom', async () => {
|
||||||
const heavy = await createUser();
|
const heavy = await createSubscribedUser();
|
||||||
const light = await createUser();
|
const light = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: heavy.id,
|
billedUserId: heavy.id,
|
||||||
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
@@ -328,7 +367,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
// start before either commits, so without serialisation both read the same
|
// start before either commits, so without serialisation both read the same
|
||||||
// "used" figure, both see enough headroom, and the user ends up over quota.
|
// "used" figure, both see enough headroom, and the user ends up over quota.
|
||||||
it('serialises two concurrent reservations so only one fits the remaining headroom', async () => {
|
it('serialises two concurrent reservations so only one fits the remaining headroom', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(30) * GIB;
|
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(30) * GIB;
|
||||||
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
|
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
|
||||||
// 30 GiB of headroom, and each request wants 20 GiB.
|
// 30 GiB of headroom, and each request wants 20 GiB.
|
||||||
@@ -358,7 +397,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('grants both concurrent reservations when there is room for both', async () => {
|
it('grants both concurrent reservations when there is room for both', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
const request = BigInt(20) * GIB;
|
const request = BigInt(20) * GIB;
|
||||||
|
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
@@ -372,7 +411,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('serialises five concurrent reservations, granting exactly the number that fit', async () => {
|
it('serialises five concurrent reservations, granting exactly the number that fit', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(50) * GIB;
|
const used = PLAN_STORAGE_LIMIT_BYTES - BigInt(50) * GIB;
|
||||||
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
|
await createUploadReservation({ billedUserId: user.id, sizeBytes: used });
|
||||||
const request = BigInt(20) * GIB;
|
const request = BigInt(20) * GIB;
|
||||||
@@ -394,8 +433,8 @@ describe('reserveStorageQuota', () => {
|
|||||||
// Different users hash to different advisory lock keys, so they must not
|
// Different users hash to different advisory lock keys, so they must not
|
||||||
// block each other.
|
// block each other.
|
||||||
it('does not serialise reservations for different users', async () => {
|
it('does not serialise reservations for different users', async () => {
|
||||||
const first = await createUser();
|
const first = await createSubscribedUser();
|
||||||
const second = await createUser();
|
const second = await createSubscribedUser();
|
||||||
const request = BigInt(150) * GIB;
|
const request = BigInt(150) * GIB;
|
||||||
|
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
@@ -410,7 +449,7 @@ describe('reserveStorageQuota', () => {
|
|||||||
|
|
||||||
describe('releaseStorageReservation', () => {
|
describe('releaseStorageReservation', () => {
|
||||||
it('deletes the reservation and frees the headroom', async () => {
|
it('deletes the reservation and frees the headroom', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
const result = await reserveStorageQuota(user.id, BigInt(10) * GIB);
|
const result = await reserveStorageQuota(user.id, BigInt(10) * GIB);
|
||||||
const reservationId = 'reservationId' in result ? result.reservationId : null;
|
const reservationId = 'reservationId' in result ? result.reservationId : null;
|
||||||
expect(reservationId).toBeTruthy();
|
expect(reservationId).toBeTruthy();
|
||||||
@@ -423,7 +462,7 @@ describe('releaseStorageReservation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op for a null id', async () => {
|
it('is a no-op for a null id', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1) });
|
await createUploadReservation({ billedUserId: user.id, sizeBytes: BigInt(1) });
|
||||||
|
|
||||||
await releaseStorageReservation(null);
|
await releaseStorageReservation(null);
|
||||||
@@ -434,8 +473,8 @@ describe('releaseStorageReservation', () => {
|
|||||||
// The billedUserId argument scopes the delete, so a caller cannot release
|
// The billedUserId argument scopes the delete, so a caller cannot release
|
||||||
// another user's reservation by guessing its id.
|
// another user's reservation by guessing its id.
|
||||||
it('refuses to delete a reservation belonging to a different billed user', async () => {
|
it('refuses to delete a reservation belonging to a different billed user', async () => {
|
||||||
const owner = await createUser();
|
const owner = await createSubscribedUser();
|
||||||
const attacker = await createUser();
|
const attacker = await createSubscribedUser();
|
||||||
const reservation = await createUploadReservation({
|
const reservation = await createUploadReservation({
|
||||||
billedUserId: owner.id,
|
billedUserId: owner.id,
|
||||||
sizeBytes: BigInt(4096),
|
sizeBytes: BigInt(4096),
|
||||||
@@ -466,7 +505,7 @@ describe('GET /api/settings/storage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('serialises the byte counts as strings so BigInt survives JSON', async () => {
|
it('serialises the byte counts as strings so BigInt survives JSON', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createUploadReservation({
|
await createUploadReservation({
|
||||||
billedUserId: user.id,
|
billedUserId: user.id,
|
||||||
sizeBytes: BigInt(20) * GIB,
|
sizeBytes: BigInt(20) * GIB,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
addWorkspaceMember,
|
addWorkspaceMember,
|
||||||
createExpiredUser,
|
createExpiredUser,
|
||||||
createProject,
|
createProject,
|
||||||
|
createSubscribedUser,
|
||||||
createUser,
|
createUser,
|
||||||
createVideo,
|
createVideo,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
@@ -145,8 +146,10 @@ describe('POST /api/workspaces', () => {
|
|||||||
expect(stored.ownerId).toBe(user.id);
|
expect(stored.ownerId).toBe(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subscribed rather than the default trial user: three workspaces is past the
|
||||||
|
// trial's ceiling, and this test is about slugs, not about billing.
|
||||||
it('gives same-named workspaces distinct slugs', async () => {
|
it('gives same-named workspaces distinct slugs', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
signedInAs(user);
|
signedInAs(user);
|
||||||
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
for (let index = 0; index < 3; index += 1) {
|
||||||
@@ -221,8 +224,10 @@ describe('POST /api/workspaces', () => {
|
|||||||
expect(await db.workspace.count()).toBe(1);
|
expect(await db.workspace.count()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The name has always promised a subscriber; it used to be handed a trial user,
|
||||||
|
// which passed only because nothing distinguished the two.
|
||||||
it('lets a subscribed user create any number of workspaces', async () => {
|
it('lets a subscribed user create any number of workspaces', async () => {
|
||||||
const user = await createUser();
|
const user = await createSubscribedUser();
|
||||||
await createWorkspace({ ownerId: user.id });
|
await createWorkspace({ ownerId: user.id });
|
||||||
await createWorkspace({ ownerId: user.id });
|
await createWorkspace({ ownerId: user.id });
|
||||||
signedInAs(user);
|
signedInAs(user);
|
||||||
@@ -236,6 +241,20 @@ describe('POST /api/workspaces', () => {
|
|||||||
expect(await db.workspace.count()).toBe(3);
|
expect(await db.workspace.count()).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses a second workspace while the owner is on a free trial', async () => {
|
||||||
|
const user = await createUser();
|
||||||
|
await createWorkspace({ ownerId: user.id });
|
||||||
|
signedInAs(user);
|
||||||
|
|
||||||
|
const response = await callRoute(
|
||||||
|
createWorkspaceRoute,
|
||||||
|
apiRequest('/api/workspaces', { body: { name: 'Second' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await db.workspace.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('lets a self-hosted instance with billing disabled create freely', async () => {
|
it('lets a self-hosted instance with billing disabled create freely', async () => {
|
||||||
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
const expired = await createExpiredUser();
|
const expired = await createExpiredUser();
|
||||||
|
|||||||
@@ -63,13 +63,15 @@ test('a new account can be registered with the invite code and then signed in',
|
|||||||
await page.getByLabel('Password').fill(E2E_PASSWORD);
|
await page.getByLabel('Password').fill(E2E_PASSWORD);
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||||
|
|
||||||
// /settings, not /onboarding, and that is the real product behaviour rather
|
// /onboarding rather than /settings, which is the whole point of the cardless
|
||||||
// than a test artefact: POST /api/auth/register does not set `trialEndsAt`, so
|
// trial: registration grants the trial (here at signup, because SMTP is unset
|
||||||
// with OPENFRAME_ENABLE_STRIPE on a brand new account has no billing access,
|
// and there is no verification step to hang it on), so
|
||||||
// and requireBillingAccessOrRedirect() on /dashboard sends it to billing
|
// requireBillingAccessOrRedirect() on /dashboard lets the account through to
|
||||||
// before it ever sees the onboarding wizard.
|
// the wizard instead of parking it on billing.
|
||||||
await expect(page).toHaveURL(/\/settings$/);
|
await expect(page).toHaveURL(/\/onboarding$/);
|
||||||
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Welcome to OpenFrame, Freshly!', level: 2 })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
// The Seed fixture only tracks rows it created itself, so remove this one by
|
// The Seed fixture only tracks rows it created itself, so remove this one by
|
||||||
// hand rather than leaving it behind for the next run.
|
// hand rather than leaving it behind for the next run.
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ export class Seed {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A user whose trial ran out and who has no subscription, so
|
* A user whose trial ran out and who has no subscription, so
|
||||||
* hasBillingAccess() is false. `billingTrialConsumedAt` is set, which is what
|
* hasBillingAccess() is false and /settings offers `Upgrade with Stripe`.
|
||||||
* makes /settings offer `Upgrade with Stripe` rather than `Start Free Trial`.
|
* `billingTrialConsumedAt` is set because the trial is once per account and
|
||||||
|
* this one has had it.
|
||||||
*/
|
*/
|
||||||
async expiredUser(): Promise<User> {
|
async expiredUser(): Promise<User> {
|
||||||
const tag = uniqueTag();
|
const tag = uniqueTag();
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
import { conversionRates } from '@/lib/analytics/scoreboard';
|
import {
|
||||||
|
COHORT_OBSERVATION_DAYS,
|
||||||
|
cohortWindows,
|
||||||
|
conversionRates,
|
||||||
|
getCardlessTrialCutover,
|
||||||
|
} from '@/lib/analytics/scoreboard';
|
||||||
|
|
||||||
const WEEK = {
|
const WEEK = {
|
||||||
visitors: 200,
|
visitors: 200,
|
||||||
@@ -34,3 +39,61 @@ describe('conversionRates', () => {
|
|||||||
expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0);
|
expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getCardlessTrialCutover', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when the deployment has not named a switchover date', () => {
|
||||||
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '');
|
||||||
|
|
||||||
|
expect(getCardlessTrialCutover()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null rather than an Invalid Date when the value is not a date', () => {
|
||||||
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', 'last tuesday');
|
||||||
|
|
||||||
|
expect(getCardlessTrialCutover()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads an ISO date', () => {
|
||||||
|
vi.stubEnv('OPENFRAME_CARDLESS_TRIAL_LAUNCHED_AT', '2026-03-01');
|
||||||
|
|
||||||
|
expect(getCardlessTrialCutover()?.toISOString()).toBe('2026-03-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cohortWindows', () => {
|
||||||
|
const CUTOVER = new Date('2026-03-01T00:00:00.000Z');
|
||||||
|
|
||||||
|
// The new cohort's window stops short of now, because an account that signed
|
||||||
|
// up yesterday has not had its 30 days to convert. Counting it would hold the
|
||||||
|
// new cohort to a shorter life than the old one and make it look worse.
|
||||||
|
it('ends the new window a full observation period before now', () => {
|
||||||
|
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(windows.afterStart.toISOString()).toBe('2026-03-01T00:00:00.000Z');
|
||||||
|
expect(windows.afterEnd.toISOString()).toBe('2026-04-01T00:00:00.000Z');
|
||||||
|
expect(COHORT_OBSERVATION_DAYS).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the old cohort a window of exactly the same length', () => {
|
||||||
|
const windows = cohortWindows(CUTOVER, new Date('2026-05-01T00:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(windows.beforeEnd.toISOString()).toBe('2026-03-01T00:00:00.000Z');
|
||||||
|
expect(windows.beforeStart.toISOString()).toBe('2026-01-29T00:00:00.000Z');
|
||||||
|
expect(windows.windowDays).toBe(31);
|
||||||
|
expect(windows.afterEnd.getTime() - windows.afterStart.getTime()).toBe(
|
||||||
|
windows.beforeEnd.getTime() - windows.beforeStart.getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses both windows to nothing before the first cohort is observable', () => {
|
||||||
|
const windows = cohortWindows(CUTOVER, new Date('2026-03-10T00:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(windows.windowDays).toBe(0);
|
||||||
|
expect(windows.afterEnd.toISOString()).toBe(windows.afterStart.toISOString());
|
||||||
|
expect(windows.beforeStart.toISOString()).toBe(windows.beforeEnd.toISOString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,18 +17,22 @@ import {
|
|||||||
hasActiveTrial,
|
hasActiveTrial,
|
||||||
hasBillingAccess,
|
hasBillingAccess,
|
||||||
hasRecoverableSubscription,
|
hasRecoverableSubscription,
|
||||||
|
isPaidTier,
|
||||||
|
keepUnexpiredTrial,
|
||||||
mapStripeSubscriptionStatus,
|
mapStripeSubscriptionStatus,
|
||||||
markSubscriptionCanceledByCustomerId,
|
markSubscriptionCanceledByCustomerId,
|
||||||
selectAuthoritativeSubscription,
|
selectAuthoritativeSubscription,
|
||||||
|
startCardlessTrial,
|
||||||
syncStripeCustomerSubscriptions,
|
syncStripeCustomerSubscriptions,
|
||||||
syncStripeSubscriptionToUser,
|
syncStripeSubscriptionToUser,
|
||||||
} from '@/lib/billing';
|
} from '@/lib/billing';
|
||||||
|
|
||||||
const dbMock = vi.hoisted(() => ({
|
const dbMock = vi.hoisted(() => ({
|
||||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
||||||
workspace: { count: vi.fn() },
|
workspace: { count: vi.fn() },
|
||||||
workspaceMember: { count: vi.fn() },
|
workspaceMember: { count: vi.fn() },
|
||||||
projectMember: { count: vi.fn() },
|
projectMember: { count: vi.fn() },
|
||||||
|
analyticsEvent: { createMany: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const stripeMock = vi.hoisted(() => ({
|
const stripeMock = vi.hoisted(() => ({
|
||||||
@@ -117,6 +121,136 @@ describe('hasActiveTrial', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('keepUnexpiredTrial', () => {
|
||||||
|
it('keeps a trial that has not run out', () => {
|
||||||
|
const future = new Date(NOW.getTime() + DAY_MS);
|
||||||
|
|
||||||
|
expect(keepUnexpiredTrial(future, NOW)).toBe(future);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a trial that has already run out', () => {
|
||||||
|
expect(keepUnexpiredTrial(new Date(NOW.getTime() - 1), NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a trial that ends exactly now', () => {
|
||||||
|
expect(keepUnexpiredTrial(new Date(NOW.getTime()), NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null rather than undefined when there is no trial', () => {
|
||||||
|
expect(keepUnexpiredTrial(null, NOW)).toBeNull();
|
||||||
|
expect(keepUnexpiredTrial(undefined, NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPaidTier', () => {
|
||||||
|
it('counts an active subscription as paid', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{ subscriptionStatus: BillingSubscriptionStatus.ACTIVE, stripeCurrentPeriodEnd: null },
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A Stripe trial was card-backed, so it is a customer in waiting rather than
|
||||||
|
// an unpaid account, and it keeps the full plan ceilings.
|
||||||
|
it('counts a Stripe trial as paid', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{ subscriptionStatus: BillingSubscriptionStatus.TRIALING, stripeCurrentPeriodEnd: null },
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a lapsed status inside a paid period as paid', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||||
|
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of the split: this account has access and no card behind it.
|
||||||
|
it('does not count a cardless trial as paid', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A checkout whose first charge never went through. Stripe hands back a
|
||||||
|
// subscription carrying a period end a month out, and reading that as payment
|
||||||
|
// would have given a free account the full 200 GB and unlimited projects.
|
||||||
|
it('does not count an incomplete subscription as paid, period end or not', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE,
|
||||||
|
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
|
||||||
|
},
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count an expired incomplete subscription as paid', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.INCOMPLETE_EXPIRED,
|
||||||
|
stripeCurrentPeriodEnd: new Date(NOW.getTime() + 30 * DAY_MS),
|
||||||
|
},
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A card that failed on renewal is a customer, not a free account: the period
|
||||||
|
// it is inside was paid for. Kept as a test because the fix above is one
|
||||||
|
// `Set.has` away from catching this case too.
|
||||||
|
it('still counts a past due subscription inside its paid period as paid', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
|
||||||
|
stripeCurrentPeriodEnd: new Date(NOW.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count an expired paid period as paid', () => {
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||||
|
stripeCurrentPeriodEnd: new Date(NOW.getTime() - DAY_MS),
|
||||||
|
},
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats everyone as paid when billing is switched off entirely', () => {
|
||||||
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isPaidTier(
|
||||||
|
{ subscriptionStatus: BillingSubscriptionStatus.FREE, stripeCurrentPeriodEnd: null },
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('hasActiveSubscription', () => {
|
describe('hasActiveSubscription', () => {
|
||||||
const expected: Record<BillingSubscriptionStatus, boolean> = {
|
const expected: Record<BillingSubscriptionStatus, boolean> = {
|
||||||
FREE: false,
|
FREE: false,
|
||||||
@@ -592,6 +726,8 @@ describe('database backed billing helpers', () => {
|
|||||||
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
|
vi.stubEnv('STRIPE_PRICE_ID', ENTITLED_PRICE);
|
||||||
dbMock.user.findUnique.mockReset();
|
dbMock.user.findUnique.mockReset();
|
||||||
dbMock.user.update.mockReset();
|
dbMock.user.update.mockReset();
|
||||||
|
dbMock.user.updateMany.mockReset();
|
||||||
|
dbMock.analyticsEvent.createMany.mockReset();
|
||||||
dbMock.workspace.count.mockReset();
|
dbMock.workspace.count.mockReset();
|
||||||
dbMock.workspaceMember.count.mockReset();
|
dbMock.workspaceMember.count.mockReset();
|
||||||
dbMock.projectMember.count.mockReset();
|
dbMock.projectMember.count.mockReset();
|
||||||
@@ -611,36 +747,31 @@ describe('database backed billing helpers', () => {
|
|||||||
await expect(getStripeCheckoutState('u1')).rejects.toThrow('User u1 not found');
|
await expect(getStripeCheckoutState('u1')).rejects.toThrow('User u1 not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports an active subscriber as active, recoverable and trial-consumed', async () => {
|
it('reports an active subscriber as active and recoverable', async () => {
|
||||||
dbMock.user.findUnique.mockResolvedValue({
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
|
subscriptionStatus: BillingSubscriptionStatus.ACTIVE,
|
||||||
billingTrialConsumedAt: new Date('2025-06-01T00:00:00Z'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(getStripeCheckoutState('u1')).resolves.toEqual({
|
await expect(getStripeCheckoutState('u1')).resolves.toEqual({
|
||||||
hasActiveSubscription: true,
|
hasActiveSubscription: true,
|
||||||
hasRecoverableSubscription: true,
|
hasRecoverableSubscription: true,
|
||||||
isTrialEligible: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a past_due subscriber as recoverable but not active', async () => {
|
it('reports a past_due subscriber as recoverable but not active', async () => {
|
||||||
dbMock.user.findUnique.mockResolvedValue({
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
|
subscriptionStatus: BillingSubscriptionStatus.PAST_DUE,
|
||||||
billingTrialConsumedAt: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(getStripeCheckoutState('u1')).resolves.toEqual({
|
await expect(getStripeCheckoutState('u1')).resolves.toEqual({
|
||||||
hasActiveSubscription: false,
|
hasActiveSubscription: false,
|
||||||
hasRecoverableSubscription: true,
|
hasRecoverableSubscription: true,
|
||||||
isTrialEligible: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a canceled subscriber as neither active nor recoverable', async () => {
|
it('reports a canceled subscriber as neither active nor recoverable', async () => {
|
||||||
dbMock.user.findUnique.mockResolvedValue({
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||||
billingTrialConsumedAt: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = await getStripeCheckoutState('u1');
|
const state = await getStripeCheckoutState('u1');
|
||||||
@@ -650,6 +781,64 @@ describe('database backed billing helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('startCardlessTrial', () => {
|
||||||
|
it('dates the trial from now and marks it consumed in the same write', async () => {
|
||||||
|
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await expect(startCardlessTrial('u1')).resolves.toBe(true);
|
||||||
|
|
||||||
|
const call = dbMock.user.updateMany.mock.calls[0][0];
|
||||||
|
expect(call.data.trialEndsAt.getTime()).toBe(
|
||||||
|
NOW.getTime() + DEFAULT_TRIAL_PERIOD_DAYS * DAY_MS
|
||||||
|
);
|
||||||
|
expect(call.data.billingTrialConsumedAt.getTime()).toBe(NOW.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard is the whole once-per-account rule. Without it a re-issued
|
||||||
|
// verification link, or a second one opened on a phone, extends the trial.
|
||||||
|
it('only writes to an account that has never had a trial', async () => {
|
||||||
|
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await startCardlessTrial('u1');
|
||||||
|
|
||||||
|
expect(dbMock.user.updateMany.mock.calls[0][0].where).toEqual({
|
||||||
|
id: 'u1',
|
||||||
|
trialEndsAt: null,
|
||||||
|
billingTrialConsumedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no trial and records nothing when the guard matched no rows', async () => {
|
||||||
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||||
|
dbMock.user.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
await expect(startCardlessTrial('u1')).resolves.toBe(false);
|
||||||
|
expect(dbMock.analyticsEvent.createMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the trial once, keyed on the account', async () => {
|
||||||
|
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||||
|
dbMock.user.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await startCardlessTrial('u1');
|
||||||
|
|
||||||
|
expect(dbMock.analyticsEvent.createMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: [expect.objectContaining({ name: 'TRIAL_STARTED', dedupeKey: 'TRIAL_STARTED:u1' })],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing is gated without billing, so a trial date would be noise. Worse, it
|
||||||
|
// would consume the trial of an instance that switches billing on later.
|
||||||
|
it('grants nothing when billing is switched off entirely', async () => {
|
||||||
|
vi.stubEnv('OPENFRAME_ENABLE_STRIPE', 'false');
|
||||||
|
|
||||||
|
await expect(startCardlessTrial('u1')).resolves.toBe(false);
|
||||||
|
expect(dbMock.user.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getWorkspaceCreationEligibility', () => {
|
describe('getWorkspaceCreationEligibility', () => {
|
||||||
function mockEligibility(options: {
|
function mockEligibility(options: {
|
||||||
user?: Record<string, unknown> | null;
|
user?: Record<string, unknown> | null;
|
||||||
@@ -684,6 +873,55 @@ describe('database backed billing helpers', () => {
|
|||||||
await expect(getWorkspaceCreationEligibility('u1')).rejects.toThrow('User u1 not found');
|
await expect(getWorkspaceCreationEligibility('u1')).rejects.toThrow('User u1 not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets an account on a cardless trial create its first workspace', async () => {
|
||||||
|
mockEligibility({
|
||||||
|
user: {
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||||
|
trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS),
|
||||||
|
billingTrialConsumedAt: NOW,
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
stripePriceId: null,
|
||||||
|
stripeCurrentPeriodEnd: null,
|
||||||
|
stripeCancelAtPeriodEnd: null,
|
||||||
|
stripeCancelAt: null,
|
||||||
|
billingAccessEndedAt: null,
|
||||||
|
},
|
||||||
|
owned: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getWorkspaceCreationEligibility('u1');
|
||||||
|
|
||||||
|
expect(result.canCreateWorkspace).toBe(true);
|
||||||
|
expect(result.subscription.isPaid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The trial ceiling. Access alone used to be enough for any number of these.
|
||||||
|
it('refuses a second workspace on a cardless trial', async () => {
|
||||||
|
mockEligibility({
|
||||||
|
user: {
|
||||||
|
subscriptionStatus: BillingSubscriptionStatus.FREE,
|
||||||
|
trialEndsAt: new Date(NOW.getTime() + 5 * DAY_MS),
|
||||||
|
billingTrialConsumedAt: NOW,
|
||||||
|
stripeCustomerId: null,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
stripePriceId: null,
|
||||||
|
stripeCurrentPeriodEnd: null,
|
||||||
|
stripeCancelAtPeriodEnd: null,
|
||||||
|
stripeCancelAt: null,
|
||||||
|
billingAccessEndedAt: null,
|
||||||
|
},
|
||||||
|
owned: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getWorkspaceCreationEligibility('u1');
|
||||||
|
|
||||||
|
expect(result.canCreateWorkspace).toBe(false);
|
||||||
|
expect(result.reason).toBe(
|
||||||
|
'Your free trial includes one workspace. Subscribe to create more.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows creation while billing access holds, whatever the counts are', async () => {
|
it('allows creation while billing access holds, whatever the counts are', async () => {
|
||||||
mockEligibility({
|
mockEligibility({
|
||||||
user: {
|
user: {
|
||||||
@@ -1002,6 +1240,26 @@ describe('database backed billing helpers', () => {
|
|||||||
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
|
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A legacy Stripe trial that has already elapsed is a reason to stamp the
|
||||||
|
// access end date, not to skip it. Treating any non-null trial date as live
|
||||||
|
// would leave a lapsed account looking like it still had access, and the
|
||||||
|
// cleanup job would never come for its storage.
|
||||||
|
it('still ends access when the Stripe trial it reports is already over', async () => {
|
||||||
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
|
||||||
|
trialEndsAt: null,
|
||||||
|
});
|
||||||
|
const elapsedTrialEnd = Math.floor((NOW.getTime() - 5 * DAY_MS) / 1000);
|
||||||
|
|
||||||
|
await syncStripeSubscriptionToUser(
|
||||||
|
stripeSub({ status: 'canceled', current_period_end: null, trial_end: elapsedTrialEnd })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((updateData().trialEndsAt as Date).getTime()).toBe(elapsedTrialEnd * 1000);
|
||||||
|
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
it('marks the trial consumed the first time an entitled trial is seen', async () => {
|
it('marks the trial consumed the first time an entitled trial is seen', async () => {
|
||||||
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
|
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', billingTrialConsumedAt: null });
|
||||||
const trialEnd = Math.floor(NOW.getTime() / 1000) + 5 * 86_400;
|
const trialEnd = Math.floor(NOW.getTime() / 1000) + 5 * 86_400;
|
||||||
@@ -1064,6 +1322,68 @@ describe('database backed billing helpers', () => {
|
|||||||
|
|
||||||
expect(updateData().stripePriceId).toBeNull();
|
expect(updateData().stripePriceId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The abandoned-checkout case. Stripe grants no trials any more, so every
|
||||||
|
// sync arrives with trial_end null, and writing that through would erase the
|
||||||
|
// days a cardless trial still had left.
|
||||||
|
it('keeps a cardless trial that has not run out when Stripe reports none', async () => {
|
||||||
|
const trialEndsAt = new Date(NOW.getTime() + 4 * DAY_MS);
|
||||||
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
billingTrialConsumedAt: NOW,
|
||||||
|
trialEndsAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncStripeSubscriptionToUser(
|
||||||
|
stripeSub({ status: 'incomplete', current_period_end: null })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateData().trialEndsAt).toBe(trialEndsAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not date the storage cleanup from today while that trial runs', async () => {
|
||||||
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
billingTrialConsumedAt: NOW,
|
||||||
|
trialEndsAt: new Date(NOW.getTime() + 4 * DAY_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncStripeSubscriptionToUser(
|
||||||
|
stripeSub({ status: 'incomplete', current_period_end: null })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateData().billingAccessEndedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a trial that has already run out', async () => {
|
||||||
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
billingTrialConsumedAt: new Date(NOW.getTime() - 30 * DAY_MS),
|
||||||
|
trialEndsAt: new Date(NOW.getTime() - DAY_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncStripeSubscriptionToUser(
|
||||||
|
stripeSub({ status: 'incomplete', current_period_end: null })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updateData().trialEndsAt).toBeNull();
|
||||||
|
expect(updateData().billingAccessEndedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still prefers the trial end Stripe reports over the stored one', async () => {
|
||||||
|
const stripeTrialEnd = Math.floor(NOW.getTime() / 1000) + 10 * 86_400;
|
||||||
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
billingTrialConsumedAt: null,
|
||||||
|
trialEndsAt: new Date(NOW.getTime() + 2 * DAY_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncStripeSubscriptionToUser(
|
||||||
|
stripeSub({ status: 'trialing', trial_end: stripeTrialEnd })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((updateData().trialEndsAt as Date).getTime()).toBe(stripeTrialEnd * 1000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('markSubscriptionCanceledByCustomerId', () => {
|
describe('markSubscriptionCanceledByCustomerId', () => {
|
||||||
@@ -1101,6 +1421,30 @@ describe('database backed billing helpers', () => {
|
|||||||
expect(updateData().billingAccessEndedAt).toBe(periodEnd);
|
expect(updateData().billingAccessEndedAt).toBe(periodEnd);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancelling a subscription does not retract days the account was already
|
||||||
|
// given, and the settings copy promises exactly this.
|
||||||
|
it('keeps a trial that has not run out and leaves access open', async () => {
|
||||||
|
const trialEndsAt = new Date(NOW.getTime() + 3 * DAY_MS);
|
||||||
|
dbMock.user.findUnique.mockResolvedValue({ id: 'u1', trialEndsAt });
|
||||||
|
|
||||||
|
await markSubscriptionCanceledByCustomerId('cus_1');
|
||||||
|
|
||||||
|
expect(updateData().trialEndsAt).toBe(trialEndsAt);
|
||||||
|
expect(updateData().billingAccessEndedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still ends access when the trial has already run out', async () => {
|
||||||
|
dbMock.user.findUnique.mockResolvedValue({
|
||||||
|
id: 'u1',
|
||||||
|
trialEndsAt: new Date(NOW.getTime() - DAY_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
await markSubscriptionCanceledByCustomerId('cus_1');
|
||||||
|
|
||||||
|
expect(updateData().trialEndsAt).toBeNull();
|
||||||
|
expect((updateData().billingAccessEndedAt as Date).getTime()).toBe(NOW.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers an explicit endedAt over the period end', async () => {
|
it('prefers an explicit endedAt over the period end', async () => {
|
||||||
dbMock.user.findUnique.mockResolvedValue({ id: 'u1' });
|
dbMock.user.findUnique.mockResolvedValue({ id: 'u1' });
|
||||||
const periodEnd = new Date('2026-02-01T00:00:00.000Z');
|
const periodEnd = new Date('2026-02-01T00:00:00.000Z');
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
import {
|
||||||
|
isDisposableEmailDomain,
|
||||||
|
isValidEmailAddress,
|
||||||
|
normalizeEmail,
|
||||||
|
} from '@/lib/email-validation';
|
||||||
|
|
||||||
describe('normalizeEmail', () => {
|
describe('normalizeEmail', () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -93,3 +97,47 @@ describe('isValidEmailAddress', () => {
|
|||||||
expect(isValidEmailAddress('ab')).toBe(false);
|
expect(isValidEmailAddress('ab')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isDisposableEmailDomain', () => {
|
||||||
|
it.each([
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
])('refuses %s', (email) => {
|
||||||
|
expect(isDisposableEmailDomain(email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Several of these providers hand out a fresh subdomain per visit, so an
|
||||||
|
// exact-match lookup would let every one of them through.
|
||||||
|
it('follows a disposable provider into its subdomains', () => {
|
||||||
|
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
|
||||||
|
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not fooled by a domain that merely ends with the same letters', () => {
|
||||||
|
expect(isDisposableEmailDomain('[email protected]')).toBe(false);
|
||||||
|
expect(isDisposableEmailDomain('[email protected]')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
// Forwarding and masking services are what privacy-minded paying customers
|
||||||
|
// actually use. Blocking them would cost real revenue.
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
'[email protected]',
|
||||||
|
])('accepts %s', (email) => {
|
||||||
|
expect(isDisposableEmailDomain(email)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores case and surrounding whitespace in the domain', () => {
|
||||||
|
expect(isDisposableEmailDomain('[email protected]')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for a string with no domain at all', () => {
|
||||||
|
expect(isDisposableEmailDomain('someone')).toBe(false);
|
||||||
|
expect(isDisposableEmailDomain('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
TRIAL_PROJECT_LIMIT,
|
||||||
|
TRIAL_STORAGE_LIMIT_BYTES,
|
||||||
|
TRIAL_WORKSPACE_LIMIT,
|
||||||
|
getStorageLimitBytes,
|
||||||
|
} from '@/lib/trial-limits';
|
||||||
|
|
||||||
|
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||||
|
|
||||||
|
describe('trial ceilings', () => {
|
||||||
|
it('holds a trial to one workspace and one project', () => {
|
||||||
|
expect(TRIAL_WORKSPACE_LIMIT).toBe(1);
|
||||||
|
expect(TRIAL_PROJECT_LIMIT).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps trial storage at 3 GiB', () => {
|
||||||
|
expect(TRIAL_STORAGE_LIMIT_BYTES).toBe(BigInt(3) * GIB);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStorageLimitBytes', () => {
|
||||||
|
const PLAN_LIMIT = BigInt(200) * GIB;
|
||||||
|
|
||||||
|
it('gives a paying account the whole plan allowance', () => {
|
||||||
|
expect(getStorageLimitBytes(true, PLAN_LIMIT)).toBe(BigInt(214748364800));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds an unpaid account to the trial ceiling', () => {
|
||||||
|
expect(getStorageLimitBytes(false, PLAN_LIMIT)).toBe(BigInt(3221225472));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A self-hosted instance can configure a plan allowance below the trial one.
|
||||||
|
// Handing a trial account more storage than the plan itself grants would be a
|
||||||
|
// strange way to run out of disk.
|
||||||
|
it('never raises an account above a plan allowance smaller than the trial ceiling', () => {
|
||||||
|
const tinyPlan = BigInt(512) * BigInt(1024) * BigInt(1024);
|
||||||
|
|
||||||
|
expect(getStorageLimitBytes(false, tinyPlan)).toBe(BigInt(536870912));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user