fix: improve claim plan flow

This commit is contained in:
Mythie
2023-10-25 22:29:51 +11:00
parent a975509923
commit 717ca8cdb2
11 changed files with 214 additions and 277 deletions

View File

@ -32,10 +32,21 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
} }
const session = await stripe.checkout.sessions.retrieve(sessionId); const session = await stripe.checkout.sessions.retrieve(sessionId);
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
if (!customerId) {
redirect('/');
}
const customer = await stripe.customers.retrieve(customerId);
if (!customer || customer.deleted) {
redirect('/');
}
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
id: Number(session.client_reference_id), id: Number(customer.metadata.userId),
}, },
}); });

View File

@ -6,6 +6,7 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from '@documenso/ui/primitives/accordion'; } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import { PricingTable } from '~/components/(marketing)/pricing-table'; import { PricingTable } from '~/components/(marketing)/pricing-table';
@ -34,6 +35,26 @@ export default function PricingPage() {
<PricingTable /> <PricingTable />
</div> </div>
<div className="mx-auto mt-36 max-w-2xl">
<h2 className="text-center text-2xl font-semibold">
None of these work for you? Try self-hosting!
</h2>
<p className="text-muted-foreground mt-4 text-center leading-relaxed">
Our self-hosted option is great for small teams and individuals who need a simple
solution. You can use our docker based setup to get started in minutes. Take control with
full customizability and data ownership.
</p>
<div className="mt-4 flex justify-center">
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
<Link href="https://github.com/documenso/documenso" target="_blank">
Get Started
</Link>
</Button>
</div>
</div>
<div className="mx-auto mt-36 max-w-2xl"> <div className="mx-auto mt-36 max-w-2xl">
{/* FAQ Section */} {/* FAQ Section */}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { HTMLAttributes, useMemo, useState } from 'react'; import { HTMLAttributes, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
@ -11,8 +11,6 @@ import { usePlausible } from 'next-plausible';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { ClaimPlanDialog } from './claim-plan-dialog';
export type PricingTableProps = HTMLAttributes<HTMLDivElement>; export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
@ -27,14 +25,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
: 'MONTHLY', : 'MONTHLY',
); );
const planId = useMemo(() => {
if (period === 'MONTHLY') {
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
}
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
}, [period]);
return ( return (
<div className={cn('', className)} {...props}> <div className={cn('', className)} {...props}>
<div className="flex items-center justify-center gap-x-6"> <div className="flex items-center justify-center gap-x-6">
@ -86,33 +76,33 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3"> <div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
<div <div
data-plan="self-hosted" data-plan="free"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg" className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
> >
<p className="text-foreground text-4xl font-medium">Self Hosted</p> <p className="text-foreground text-4xl font-medium">Free Plan</p>
<p className="text-primary mt-2.5 text-xl font-medium">Free</p> <p className="text-primary mt-2.5 text-xl font-medium">$0</p>
<p className="text-foreground mt-4 max-w-[30ch] text-center"> <p className="text-foreground mt-4 max-w-[30ch] text-center">
For small teams and individuals who need a simple solution For small teams and individuals with basic needs.
</p> </p>
<Link <Button className="rounded-full text-base" asChild>
href="https://github.com/documenso/documenso" <Link
target="_blank" href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
className="mt-6" target="_blank"
onClick={() => event('view-github')} className="mt-6"
> >
<Button className="rounded-full text-base">View on Github</Button> Signup Now
</Link> </Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y"> <div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium">Host your own instance</p> <p className="text-foreground py-4">5 standard documents per month</p>
<p className="text-foreground py-4">Full Control</p> <p className="text-foreground py-4">Up to 10 recipients per document</p>
<p className="text-foreground py-4">Customizability</p> <p className="text-foreground py-4">No credit card required</p>
<p className="text-foreground py-4">Docker Ready</p>
<p className="text-foreground py-4">Community Support</p>
<p className="text-foreground py-4">Free, Forever</p>
</div> </div>
<div className="flex-1" />
</div> </div>
<div <div
@ -131,9 +121,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
For fast-growing companies that aim to scale across multiple teams. For fast-growing companies that aim to scale across multiple teams.
</p> </p>
<ClaimPlanDialog planId={planId}> <Button className="mt-6 rounded-full text-base" asChild>
<Button className="mt-6 rounded-full text-base">Signup Now</Button> <Link href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}>Signup Now</Link>
</ClaimPlanDialog> </Button>
<div className="mt-8 flex w-full flex-col divide-y"> <div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium"> <p className="text-foreground py-4 font-medium">

View File

@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { hashSync } from '@documenso/lib/server-only/auth/hash'; import { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -36,64 +36,38 @@ export default async function handler(
where: { where: {
email: email.toLowerCase(), email: email.toLowerCase(),
}, },
include: {
Subscription: true,
},
}); });
if (user && user.Subscription) { if (user) {
return res.status(200).json({ return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`, redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
}); });
} }
const password = Math.random().toString(36).slice(2, 9); const clientReferenceId = randomUUID();
const passwordHash = hashSync(password);
const { id: userId } = await prisma.user.upsert({
where: {
email: email.toLowerCase(),
},
create: {
email: email.toLowerCase(),
name,
password: passwordHash,
},
update: {
name,
password: passwordHash,
},
});
await redis.set(`user:${userId}:temp-password`, password, {
// expire in 24 hours
ex: 60 * 60 * 24,
});
const signatureDataUrlKey = randomUUID();
if (signatureDataUrl) { if (signatureDataUrl) {
await redis.set(`signature:${signatureDataUrlKey}`, signatureDataUrl, { await redis.set(`signature:${clientReferenceId}`, signatureDataUrl, {
// expire in 7 days // expire in 7 days
ex: 60 * 60 * 24 * 7, ex: 60 * 60 * 24 * 7,
}); });
} }
const metadata: Record<string, string> = { const metadata: TEarlyAdopterCheckoutMetadataSchema = {
name, name,
email, email,
signatureText: signatureText || name, signatureText: signatureText || name,
source: 'landing', source: 'marketing',
}; };
if (signatureDataUrl) { if (signatureDataUrl) {
metadata.signatureDataUrl = signatureDataUrlKey; metadata.signatureDataUrl = clientReferenceId;
} }
const checkout = await stripe.checkout.sessions.create({ const checkout = await stripe.checkout.sessions.create({
customer_email: email, customer_email: email,
client_reference_id: userId.toString(), // Using the UUID here means our webhook will not try to use it as a user ID.
payment_method_types: ['card'], client_reference_id: clientReferenceId,
line_items: [ line_items: [
{ {
price: planId, price: planId,
@ -104,9 +78,7 @@ export default async function handler(
metadata, metadata,
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`, success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent( cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`,
email,
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
}); });
if (!checkout.url) { if (!checkout.url) {

View File

@ -1,41 +0,0 @@
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({
name,
email,
planId,
signatureDataUrl,
signatureText,
}: TClaimPlanRequestSchema) => {
const response = await fetch('/api/claim-plan', {
method: 'POST',
body: JSON.stringify({
name,
email,
planId,
signatureDataUrl,
signatureText,
}),
headers: {
'Content-Type': 'application/json',
},
});
const body = await response.json();
if (response.status !== 200) {
throw new Error('Failed to claim plan');
}
const safeBody = ZClaimPlanResponseSchema.safeParse(body);
if (!safeBody.success) {
throw new Error('Failed to claim plan');
}
if ('error' in safeBody.data) {
throw new Error(safeBody.data.error);
}
return safeBody.data.redirectUrl;
};

View File

@ -1,37 +0,0 @@
import { z } from 'zod';
export const ZClaimPlanRequestSchema = z
.object({
email: z
.string()
.email()
.transform((value) => value.toLowerCase()),
name: z.string(),
planId: z.string(),
})
.and(
z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null(),
}),
z.object({
signatureDataUrl: z.null(),
signatureText: z.string().min(1),
}),
]),
);
export type TClaimPlanRequestSchema = z.infer<typeof ZClaimPlanRequestSchema>;
export const ZClaimPlanResponseSchema = z
.object({
redirectUrl: z.string(),
})
.or(
z.object({
error: z.string(),
}),
);
export type TClaimPlanResponseSchema = z.infer<typeof ZClaimPlanResponseSchema>;

View File

@ -1,126 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TClaimPlanResponseSchema>,
) {
try {
const { method } = req;
if (method?.toUpperCase() !== 'POST') {
return res.status(405).json({
error: 'Method not allowed',
});
}
const safeBody = ZClaimPlanRequestSchema.safeParse(req.body);
if (!safeBody.success) {
return res.status(400).json({
error: 'Bad request',
});
}
const { email, name, planId, signatureDataUrl, signatureText } = safeBody.data;
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
include: {
Subscription: true,
},
});
if (user && user.Subscription) {
return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
});
}
const password = Math.random().toString(36).slice(2, 9);
const passwordHash = hashSync(password);
const { id: userId } = await prisma.user.upsert({
where: {
email: email.toLowerCase(),
},
create: {
email: email.toLowerCase(),
name,
password: passwordHash,
},
update: {
name,
password: passwordHash,
},
});
await redis.set(`user:${userId}:temp-password`, password, {
// expire in 24 hours
ex: 60 * 60 * 24,
});
const signatureDataUrlKey = randomUUID();
if (signatureDataUrl) {
await redis.set(`signature:${signatureDataUrlKey}`, signatureDataUrl, {
// expire in 7 days
ex: 60 * 60 * 24 * 7,
});
}
const metadata: Record<string, string> = {
name,
email,
signatureText: signatureText || name,
source: 'landing',
};
if (signatureDataUrl) {
metadata.signatureDataUrl = signatureDataUrlKey;
}
const checkout = await stripe.checkout.sessions.create({
customer_email: email,
client_reference_id: userId.toString(),
payment_method_types: ['card'],
line_items: [
{
price: planId,
quantity: 1,
},
],
mode: 'subscription',
metadata,
allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent(
email,
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
});
if (!checkout.url) {
throw new Error('Checkout URL not found');
}
return res.json({
redirectUrl: checkout.url,
});
} catch (error) {
console.error(error);
return res.status(500).json({
error: 'Internal server error',
});
}
}

View File

@ -0,0 +1,13 @@
import { z } from 'zod';
export const ZEarlyAdopterCheckoutMetadataSchema = z.object({
name: z.string(),
email: z.string(),
signatureText: z.string(),
signatureDataUrl: z.string().optional(),
source: z.literal('marketing'),
});
export type TEarlyAdopterCheckoutMetadataSchema = z.infer<
typeof ZEarlyAdopterCheckoutMetadataSchema
>;

View File

@ -7,6 +7,7 @@ import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { onEarlyAdoptersCheckout } from './on-early-adopters-checkout';
import { onSubscriptionDeleted } from './on-subscription-deleted'; import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated'; import { onSubscriptionUpdated } from './on-subscription-updated';
@ -52,6 +53,10 @@ export const stripeWebhookHandler = async (
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session; const session = event.data.object as Stripe.Checkout.Session;
if (session.metadata?.source === 'marketing') {
await onEarlyAdoptersCheckout({ session });
}
const customerId = const customerId =
typeof session.customer === 'string' ? session.customer : session.customer?.id; typeof session.customer === 'string' ? session.customer : session.customer?.id;

View File

@ -0,0 +1,126 @@
import Stripe from 'stripe';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { redis } from '@documenso/lib/server-only/redis';
import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
FieldType,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { ZEarlyAdopterCheckoutMetadataSchema } from './early-adopter-checkout-metadata';
export type OnEarlyAdoptersCheckoutOptions = {
session: Stripe.Checkout.Session;
};
export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersCheckoutOptions) => {
try {
const safeMetadata = ZEarlyAdopterCheckoutMetadataSchema.safeParse(session.metadata);
if (!safeMetadata.success) {
return;
}
const { email, name, signatureText, signatureDataUrl: signatureDataUrlRef } = safeMetadata.data;
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (user) {
return;
}
const tempPassword = nanoid(12);
const newUser = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashSync(tempPassword),
signature: signatureDataUrlRef,
},
});
await redis.set(`user:${newUser.id}:temp-password`, tempPassword, {
// expire in 1 week
ex: 60 * 60 * 24 * 7,
});
const signatureDataUrl = await redis.get<string>(`signature:${session.client_reference_id}`);
const documentBuffer = await fetch(
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/documenso-supporter-pledge.pdf`,
).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({
name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer),
});
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: newUser.id,
documentDataId,
},
});
const recipient = await prisma.recipient.create({
data: {
name,
email: email.toLowerCase(),
token: alphaid(),
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
documentId: document.id,
},
});
await prisma.field.create({
data: {
type: FieldType.SIGNATURE,
recipientId: recipient.id,
documentId: document.id,
page: 1,
positionX: 12.2781,
positionY: 81.5789,
height: 6.8649,
width: 29.5857,
inserted: true,
customText: '',
Signature: {
create: {
typedSignature: signatureDataUrl ? null : signatureText || name,
signatureImageAsBase64: signatureDataUrl,
recipientId: recipient.id,
},
},
},
});
await sealDocument({
documentId: document.id,
sendEmail: false,
});
} catch (error) {
// We don't want to break the checkout process if something goes wrong here.
// This is an additive experience for early adopters, breaking their ability
// join would be far worse than not having a signed pledge.
console.error(error);
}
};

View File

@ -14,9 +14,10 @@ import { sendCompletedEmail } from './send-completed-email';
export type SealDocumentOptions = { export type SealDocumentOptions = {
documentId: number; documentId: number;
sendEmail?: boolean;
}; };
export const sealDocument = async ({ documentId }: SealDocumentOptions) => { export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => {
'use server'; 'use server';
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
@ -91,5 +92,7 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
}, },
}); });
await sendCompletedEmail({ documentId }); if (sendEmail) {
await sendCompletedEmail({ documentId });
}
}; };