Create successful payment

This commit is contained in:
Joe Au-Yeung 2022-11-02 11:26:01 -04:00
parent a9e897d99e
commit c60e9bdc32
7 changed files with 171 additions and 108 deletions

View File

@ -1,90 +1,68 @@
import { PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js"; import { PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NewTeamData } from "@calcom/features/ee/teams/lib/types"; import { NewTeamData } from "@calcom/features/ee/teams/lib/types";
import { CAL_URL } from "@calcom/lib/constants";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { Switch, Button } from "@calcom/ui/v2"; import { Switch, Button } from "@calcom/ui/v2";
const PurchaseNewTeam = ({ const PurchaseNewTeam = ({
newTeamData,
paymentIntent, paymentIntent,
clientSecret, clientSecret,
teamPrices, total,
billingFrequency,
newTeamData,
}: { }: {
newTeamData: NewTeamData;
paymentIntent: string; paymentIntent: string;
clientSecret: string; clientSecret: string;
teamPrices: { total: number;
monthly: number; billingFrequency: string;
yearly: number; newTeamData: NewTeamData;
};
}) => { }) => {
const [message, setMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [billingFrequency, setBillingFrequency] = useState("monthly");
const [paymentProcessing, setPaymentProcessing] = useState(false); const [paymentProcessing, setPaymentProcessing] = useState(false);
const stripe = useStripe(); const stripe = useStripe();
const elements = useElements(); const elements = useElements();
const router = useRouter();
const updatePaymentIntentMutation = trpc.useMutation(["viewer.teams.mutatePaymentIntent"]); const createTeamMutation = trpc.useMutation(["viewer.teams.createTeam"], {
onSuccess: (data) => {
router.push(`${CAL_URL}/settings/teams/${data.id}/profile`);
},
});
// Handle Stripe payment const handleSubmit = async () => {
useEffect(() => { if (!stripe || !elements) return;
if (!stripe) {
return;
}
if (!paymentIntent) { setPaymentProcessing(true);
return;
}
stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => { const { error } = await stripe.confirmPayment({
switch (paymentIntent.status) { elements,
case "succeeded": confirmParams: {
setMessage("Payment succeeded!"); return_url: `${CAL_URL}/settings/profile`,
break; },
case "processing": redirect: "if_required",
setMessage("Your payment is processing.");
break;
case "requires_payment_method":
setMessage("Your payment was not successful, please try again.");
break;
default:
setMessage("Something went wrong.");
break;
}
}); });
}, [stripe]);
if (error) {
console.log("🚀 ~ file: PurchaseNewTeam.tsx ~ line 71 ~ handleSubmit ~ error", error);
} else {
createTeamMutation.mutate(newTeamData);
}
};
return ( return (
<> <>
{/* <div className="flex justify-between">
<p>Total</p>
<div>
<p>
{newTeamData.members.length} members x ${teamPrices[billingFrequency as keyof typeof teamPrices]}{" "}
/ {billingFrequency} ={" "}
{newTeamData.members.length * teamPrices[billingFrequency as keyof typeof teamPrices]}
</p>
</div>
</div>
<hr />
<div className="mt-4 flex space-x-2">
<Switch onClick={() => setBillingFrequency(billingFrequency === "monthly" ? "yearly" : "monthly")} />
<p>
Switch to yearly and save{" "}
{newTeamData.members.length * (teamPrices.monthly * 12 - teamPrices.yearly)}
</p>
</div> */}
<hr className="my-4" />
<PaymentElement /> <PaymentElement />
<Button <Button
className="mt-4 w-full justify-center" className="mt-4 w-full justify-center"
loading={paymentProcessing} loading={paymentProcessing}
onClick={() => handleSubmit()}> onClick={() => handleSubmit()}>
Pay ${teamPrices[billingFrequency as keyof typeof teamPrices]} Pay ${total} / {billingFrequency}
</Button> </Button>
<p>Error processing payment: {errorMessage}</p>
</> </>
); );
}; };

View File

@ -1,4 +1,4 @@
import { Elements } from "@stripe/react-stripe-js"; import { Elements, useElements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js"; import { loadStripe } from "@stripe/stripe-js";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -45,6 +45,7 @@ const CreateNewTeamPage = () => {
slug: "", slug: "",
logo: "", logo: "",
members: [], members: [],
billingFrequency: "monthly",
}); });
const [clientSecret, setClientSecret] = useState(""); const [clientSecret, setClientSecret] = useState("");
const [paymentIntent, setPaymentIntent] = useState(""); const [paymentIntent, setPaymentIntent] = useState("");
@ -82,25 +83,19 @@ const CreateNewTeamPage = () => {
const currentStepIndex = steps.indexOf(currentStep); const currentStepIndex = steps.indexOf(currentStep);
const createPaymentIntentMutation = trpc.useMutation(["viewer.teams.mutatePaymentIntent"], { const createPaymentIntentMutation = trpc.useMutation(["viewer.teams.createPaymentIntent"], {
onSuccess: (data) => { onSuccess: (data) => {
console.log("🚀 ~ file: [[...step]].tsx ~ line 86 ~ CreateNewTeamPage ~ data", data); setClientSecret(data.clientSecret);
setClientSecret(data.client_secret); goToIndex(2);
setPaymentIntent(data.id);
}, },
}); });
const getTeamPricesQuery = trpc.useQuery(["viewer.teams.getTeamPrices"], { const getTeamPricesQuery = trpc.useQuery(["viewer.teams.getTeamPrices"], {
onSuccess: (data) => { onSuccess: (data) => {
setTeamPrices(data); setTeamPrices(data);
console.log("🚀 ~ file: [[...step]].tsx ~ line 95 ~ CreateNewTeamPage ~ data", data);
}, },
}); });
useEffect(() => {
createPaymentIntentMutation.mutate({ amount: 1500 });
}, []);
return ( return (
<div <div
className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black" className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black"
@ -133,7 +128,6 @@ const CreateNewTeamPage = () => {
<CreateNewTeam <CreateNewTeam
nextStep={(values: NewTeamFormValues) => { nextStep={(values: NewTeamFormValues) => {
setNewTeamData({ ...values, members: [] }); setNewTeamData({ ...values, members: [] });
localStorage.setItem("newTeamValues", JSON.stringify(values));
goToIndex(1); goToIndex(1);
}} }}
/> />
@ -146,17 +140,21 @@ const CreateNewTeamPage = () => {
{currentStep === "add-team-members" && ( {currentStep === "add-team-members" && (
<AddNewTeamMembers <AddNewTeamMembers
teamPrices={teamPrices} teamPrices={teamPrices}
nextStep={(values: PendingMember[]) => { nextStep={(values: {
console.log("🚀 ~ file: [[...step]].tsx ~ line 144 ~ CreateNewTeamPage ~ values", values); members: PendingMember[];
setNewTeamData({ ...newTeamData, members: [...values] }); billingFrequency: "monthly" | "yearly";
// localStorage.removeItem("newTeamValues"); }) => {
// purchaseTeamMutation.mutate({ console.log("🚀 ~ file: [[...step]].tsx ~ line 148 ~ CreateNewTeamPage ~ values", values);
// ...newTeamData, createPaymentIntentMutation.mutate({
// members: [...values], teamName: newTeamData.name,
// language: i18n.language, billingFrequency: values.billingFrequency,
// }); seats: values.members.length,
createPaymentIntentMutation.mutate({ amount: values.length * 15 * 100 }); });
goToIndex(2); setNewTeamData({
...newTeamData,
members: [...values.members],
billingFrequency: values.billingFrequency,
});
}} }}
/> />
)} )}
@ -164,9 +162,13 @@ const CreateNewTeamPage = () => {
{currentStep === "purchase-new-team" && ( {currentStep === "purchase-new-team" && (
<Elements stripe={stripe} options={{ clientSecret }}> <Elements stripe={stripe} options={{ clientSecret }}>
<PurchaseNewTeam <PurchaseNewTeam
newTeamData={newTeamData}
paymentIntent={paymentIntent} paymentIntent={paymentIntent}
clientSecret={clientSecret} clientSecret={clientSecret}
total={
newTeamData.members.length *
teamPrices[newTeamData.billingFrequency as keyof typeof teamPrices]
}
newTeamData={newTeamData}
/> />
</Elements> </Elements>
)} )}

View File

@ -21,7 +21,7 @@ const AddNewTeamMembers = ({
nextStep, nextStep,
teamPrices, teamPrices,
}: { }: {
nextStep: (values: PendingMember[]) => void; nextStep: (values: { members: PendingMember[]; billingFrequency: "monthly" | "yearly" }) => void;
teamPrices: { teamPrices: {
monthly: number; monthly: number;
yearly: number; yearly: number;
@ -68,7 +68,7 @@ const AddNewTeamMembers = ({
name: session?.data.user.name || "", name: session?.data.user.name || "",
email: session?.data.user.email || "", email: session?.data.user.email || "",
username: session?.data.user.username || "", username: session?.data.user.username || "",
userId: session?.data.user.id || "", id: session?.data.user.id || "",
role: "OWNER", role: "OWNER",
}); });
} }
@ -99,7 +99,7 @@ const AddNewTeamMembers = ({
return ( return (
<> <>
<Form form={formMethods} handleSubmit={(values) => nextStep(values.members)}> <Form form={formMethods} handleSubmit={(values) => nextStep(values)}>
<Controller <Controller
name="members" name="members"
render={({ field: { value } }) => ( render={({ field: { value } }) => (
@ -179,15 +179,24 @@ const AddNewTeamMembers = ({
</div> </div>
</div> </div>
<hr /> <hr />
<div className="mt-4 flex space-x-2"> <Controller
<Switch control={formMethods.control}
onClick={() => setBillingFrequency(billingFrequency === "monthly" ? "yearly" : "monthly")} name="billingFrequency"
/> defaultValue={"monthly"}
<p> render={(field: { value }) => (
Switch to yearly and save {numberOfMembers * (teamPrices.monthly * 12 - teamPrices.yearly)} <div className="mt-4 flex space-x-2">
</p> <Switch
</div> onCheckedChange={(e) =>
formMethods.setValue("billingFrequency", e ? "yearly" : "monthly")
}
/>
<p>
Switch to yearly and save{" "}
{numberOfMembers * (teamPrices.monthly * 12 - teamPrices.yearly)}
</p>
</div>
)}
/>
<Button EndIcon={Icon.FiArrowRight} className="mt-6 w-full justify-center" type="submit"> <Button EndIcon={Icon.FiArrowRight} className="mt-6 w-full justify-center" type="submit">
{t("checkout")} {t("checkout")}
</Button> </Button>

View File

@ -15,18 +15,19 @@ export const createMember = async ({
pendingMember, pendingMember,
language, language,
teamSubscriptionActive, teamSubscriptionActive,
}): { }: {
teamId: number; teamId: number;
teamName: string; teamName: string;
inviter: string;
pendingMember: PendingMember; pendingMember: PendingMember;
language: string; language: string;
teamSubscriptionActive?: boolean; teamSubscriptionActive?: boolean;
} => { }) => {
if (pendingMember.username) { if (pendingMember.username & pendingMember.id) {
await prisma.membership.create({ await prisma.membership.create({
data: { data: {
teamId, teamId,
userId: pendingMember.userId, userId: pendingMember.id,
role: pendingMember.role as MembershipRole, role: pendingMember.role as MembershipRole,
}, },
}); });

View File

@ -16,6 +16,30 @@ export const getTeamPricing = async () => {
}; };
}; };
export const createTeamCustomer = async (teamName: string, ownerEmail: string) => {
return await stripe.customers.create({
name: teamName,
email: ownerEmail,
});
};
export const createTeamSubscription = async (customerId: string, billingFrequency: string, seats: number) => {
return await stripe.subscriptions.create({
customer: customerId,
items: [
{
price:
billingFrequency === "monthly"
? process.env.STRIPE_TEAM_MONTHLY_PRICE_ID
: process.env.STRIPE_TEAM_YEARLY_PRICE_ID,
quantity: seats,
},
],
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
});
};
export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; email: string }) => { export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; email: string }) => {
const { teamId, seats, email } = input; const { teamId, seats, email } = input;
return await stripe.checkout.sessions.create({ return await stripe.checkout.sessions.create({

View File

@ -18,4 +18,5 @@ export interface PendingMember {
sendInviteEmail?: boolean; sendInviteEmail?: boolean;
} }
export type NewTeamData = NewTeamFormValues & NewTeamMembersFieldArray; export type NewTeamData = NewTeamFormValues &
NewTeamMembersFieldArray & { billingFrequency: "monthly" | "yearly" };

View File

@ -14,6 +14,8 @@ import { createMember } from "@calcom/features/ee/teams/lib/inviteMember";
import { import {
deleteTeamFromStripe, deleteTeamFromStripe,
purchaseTeamSubscription, purchaseTeamSubscription,
createTeamCustomer,
createTeamSubscription,
createPaymentIntent, createPaymentIntent,
getTeamPricing, getTeamPricing,
updatePaymentIntent, updatePaymentIntent,
@ -688,23 +690,69 @@ export const viewerTeamsRouter = createProtectedRouter()
return teamPrices; return teamPrices;
}, },
}) })
.mutation("mutatePaymentIntent", { .mutation("createPaymentIntent", {
input: z.object({ input: z.object({
amount: z.number(), teamName: z.string(),
paymentIntentId: z.string().optional(), billingFrequency: z.string(),
seats: z.number(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const { amount, paymentIntentId } = input; console.log("🚀 ~ file: teams.tsx ~ line 699 ~ resolve ~ input", input);
try { // First create the customer
// If there is no payment intent, then create one const customer = await createTeamCustomer(input.teamName, ctx.user.email);
if (!paymentIntentId) {
const paymentIntent = await createPaymentIntent({ amount, receiptEmail: ctx.user.email });
return paymentIntent;
}
await updatePaymentIntent({ amount, paymentIntentId }); // Create the subscription for the team
} catch (e) { const subscription = await createTeamSubscription(customer.id, input.billingFrequency, input.seats);
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: e.message });
// We just need the client secret for the payment intent
return { clientSecret: subscription.latest_invoice.payment_intent.client_secret };
},
})
.mutation("createTeam", {
input: z.object({
name: z.string(),
slug: z.string(),
logo: z.string().optional(),
members: z.array(
z.object({
name: z.union([z.string(), z.null()]),
email: z.string(),
id: z.number().optional(),
username: z.union([z.string(), z.null()]),
role: z.union([z.literal("OWNER"), z.literal("ADMIN"), z.literal("MEMBER")]),
avatar: z.union([z.string(), z.null()]).optional(),
sendInviteEmail: z.boolean().optional(),
})
),
}),
async resolve({ ctx, input }) {
const { name, slug, logo, members } = input;
const createTeam = await ctx.prisma.team.create({
data: {
name,
slug,
logo,
subscriptionStatus: "ACTIVE",
members: {
create: {
userId: ctx.user.id,
role: MembershipRole.OWNER,
accepted: true,
},
},
},
});
for (const member of members) {
if (member.id !== ctx.user.id)
createMember({
teamId: createTeam.id,
teamName: name,
inviter: ctx.user.name || "Owner",
pendingMember: member,
});
} }
return createTeam;
}, },
}); });