From 717ca8cdb28f6b3f73e2358343500cb13c5e37e1 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 25 Oct 2023 22:29:51 +1100 Subject: [PATCH] fix: improve claim plan flow --- .../src/app/(marketing)/claimed/page.tsx | 13 +- .../src/app/(marketing)/pricing/page.tsx | 21 +++ .../components/(marketing)/pricing-table.tsx | 54 +++----- .../src/pages/api/claim-plan/index.ts | 48 ++----- apps/web/src/api/claim-plan/fetcher.ts | 41 ------ apps/web/src/api/claim-plan/types.ts | 37 ----- apps/web/src/pages/api/claim-plan/index.ts | 126 ------------------ .../early-adopter-checkout-metadata.ts | 13 ++ .../ee/server-only/stripe/webhook/handler.ts | 5 + .../webhook/on-early-adopters-checkout.ts | 126 ++++++++++++++++++ .../lib/server-only/document/seal-document.ts | 7 +- 11 files changed, 214 insertions(+), 277 deletions(-) delete mode 100644 apps/web/src/api/claim-plan/fetcher.ts delete mode 100644 apps/web/src/api/claim-plan/types.ts delete mode 100644 apps/web/src/pages/api/claim-plan/index.ts create mode 100644 packages/ee/server-only/stripe/webhook/early-adopter-checkout-metadata.ts create mode 100644 packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts diff --git a/apps/marketing/src/app/(marketing)/claimed/page.tsx b/apps/marketing/src/app/(marketing)/claimed/page.tsx index 90e7a9843..f709e46e7 100644 --- a/apps/marketing/src/app/(marketing)/claimed/page.tsx +++ b/apps/marketing/src/app/(marketing)/claimed/page.tsx @@ -32,10 +32,21 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan } 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({ where: { - id: Number(session.client_reference_id), + id: Number(customer.metadata.userId), }, }); diff --git a/apps/marketing/src/app/(marketing)/pricing/page.tsx b/apps/marketing/src/app/(marketing)/pricing/page.tsx index c1f8299f3..f6c4a63ca 100644 --- a/apps/marketing/src/app/(marketing)/pricing/page.tsx +++ b/apps/marketing/src/app/(marketing)/pricing/page.tsx @@ -6,6 +6,7 @@ import { AccordionItem, AccordionTrigger, } from '@documenso/ui/primitives/accordion'; +import { Button } from '@documenso/ui/primitives/button'; import { PricingTable } from '~/components/(marketing)/pricing-table'; @@ -34,6 +35,26 @@ export default function PricingPage() { +
+

+ None of these work for you? Try self-hosting! +

+ +

+ 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. +

+ +
+ +
+
+
{/* FAQ Section */} diff --git a/apps/marketing/src/components/(marketing)/pricing-table.tsx b/apps/marketing/src/components/(marketing)/pricing-table.tsx index 72eb5d155..19da6920a 100644 --- a/apps/marketing/src/components/(marketing)/pricing-table.tsx +++ b/apps/marketing/src/components/(marketing)/pricing-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { HTMLAttributes, useMemo, useState } from 'react'; +import { HTMLAttributes, useState } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; @@ -11,8 +11,6 @@ import { usePlausible } from 'next-plausible'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; -import { ClaimPlanDialog } from './claim-plan-dialog'; - export type PricingTableProps = HTMLAttributes; const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; @@ -27,14 +25,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => { : '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 (
@@ -86,33 +76,33 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
-

Self Hosted

-

Free

+

Free Plan

+

$0

- For small teams and individuals who need a simple solution + For small teams and individuals with basic needs.

- event('view-github')} - > - - +
-

Host your own instance

-

Full Control

-

Customizability

-

Docker Ready

-

Community Support

-

Free, Forever

+

5 standard documents per month

+

Up to 10 recipients per document

+

No credit card required

+ +
{ For fast-growing companies that aim to scale across multiple teams.

- - - +

diff --git a/apps/marketing/src/pages/api/claim-plan/index.ts b/apps/marketing/src/pages/api/claim-plan/index.ts index 057485f35..b2fd41727 100644 --- a/apps/marketing/src/pages/api/claim-plan/index.ts +++ b/apps/marketing/src/pages/api/claim-plan/index.ts @@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; 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 { stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; @@ -36,64 +36,38 @@ export default async function handler( where: { email: email.toLowerCase(), }, - include: { - Subscription: true, - }, }); - if (user && user.Subscription) { + if (user) { 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(); + const clientReferenceId = randomUUID(); if (signatureDataUrl) { - await redis.set(`signature:${signatureDataUrlKey}`, signatureDataUrl, { + await redis.set(`signature:${clientReferenceId}`, signatureDataUrl, { // expire in 7 days ex: 60 * 60 * 24 * 7, }); } - const metadata: Record = { + const metadata: TEarlyAdopterCheckoutMetadataSchema = { name, email, signatureText: signatureText || name, - source: 'landing', + source: 'marketing', }; if (signatureDataUrl) { - metadata.signatureDataUrl = signatureDataUrlKey; + metadata.signatureDataUrl = clientReferenceId; } const checkout = await stripe.checkout.sessions.create({ customer_email: email, - client_reference_id: userId.toString(), - payment_method_types: ['card'], + // Using the UUID here means our webhook will not try to use it as a user ID. + client_reference_id: clientReferenceId, line_items: [ { price: planId, @@ -104,9 +78,7 @@ export default async function handler( 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`, + cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`, }); if (!checkout.url) { diff --git a/apps/web/src/api/claim-plan/fetcher.ts b/apps/web/src/api/claim-plan/fetcher.ts deleted file mode 100644 index 0e533be5e..000000000 --- a/apps/web/src/api/claim-plan/fetcher.ts +++ /dev/null @@ -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; -}; diff --git a/apps/web/src/api/claim-plan/types.ts b/apps/web/src/api/claim-plan/types.ts deleted file mode 100644 index 103a1336c..000000000 --- a/apps/web/src/api/claim-plan/types.ts +++ /dev/null @@ -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; - -export const ZClaimPlanResponseSchema = z - .object({ - redirectUrl: z.string(), - }) - .or( - z.object({ - error: z.string(), - }), - ); - -export type TClaimPlanResponseSchema = z.infer; diff --git a/apps/web/src/pages/api/claim-plan/index.ts b/apps/web/src/pages/api/claim-plan/index.ts deleted file mode 100644 index 057485f35..000000000 --- a/apps/web/src/pages/api/claim-plan/index.ts +++ /dev/null @@ -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, -) { - 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 = { - 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', - }); - } -} diff --git a/packages/ee/server-only/stripe/webhook/early-adopter-checkout-metadata.ts b/packages/ee/server-only/stripe/webhook/early-adopter-checkout-metadata.ts new file mode 100644 index 000000000..7d8c65a09 --- /dev/null +++ b/packages/ee/server-only/stripe/webhook/early-adopter-checkout-metadata.ts @@ -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 +>; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 9d444eb84..dd2079122 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -7,6 +7,7 @@ import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { prisma } from '@documenso/prisma'; +import { onEarlyAdoptersCheckout } from './on-early-adopters-checkout'; import { onSubscriptionDeleted } from './on-subscription-deleted'; import { onSubscriptionUpdated } from './on-subscription-updated'; @@ -52,6 +53,10 @@ export const stripeWebhookHandler = async ( // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const session = event.data.object as Stripe.Checkout.Session; + if (session.metadata?.source === 'marketing') { + await onEarlyAdoptersCheckout({ session }); + } + const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id; diff --git a/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts new file mode 100644 index 000000000..a89537ffe --- /dev/null +++ b/packages/ee/server-only/stripe/webhook/on-early-adopters-checkout.ts @@ -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(`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); + } +}; diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts index 179f33fb3..318d540b8 100644 --- a/packages/lib/server-only/document/seal-document.ts +++ b/packages/lib/server-only/document/seal-document.ts @@ -14,9 +14,10 @@ import { sendCompletedEmail } from './send-completed-email'; export type SealDocumentOptions = { documentId: number; + sendEmail?: boolean; }; -export const sealDocument = async ({ documentId }: SealDocumentOptions) => { +export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => { 'use server'; const document = await prisma.document.findFirstOrThrow({ @@ -91,5 +92,7 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => { }, }); - await sendCompletedEmail({ documentId }); + if (sendEmail) { + await sendCompletedEmail({ documentId }); + } };