mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
fix: improve claim plan flow
This commit is contained in:
@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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>;
|
|
||||||
@ -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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
>;
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user