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 dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { NewTeamData } from "@calcom/features/ee/teams/lib/types";
import { CAL_URL } from "@calcom/lib/constants";
import { trpc } from "@calcom/trpc/react";
import { Switch, Button } from "@calcom/ui/v2";
const PurchaseNewTeam = ({
newTeamData,
paymentIntent,
clientSecret,
teamPrices,
total,
billingFrequency,
newTeamData,
}: {
newTeamData: NewTeamData;
paymentIntent: string;
clientSecret: string;
teamPrices: {
monthly: number;
yearly: number;
};
total: number;
billingFrequency: string;
newTeamData: NewTeamData;
}) => {
const [message, setMessage] = useState("");
const [billingFrequency, setBillingFrequency] = useState("monthly");
const [errorMessage, setErrorMessage] = useState("");
const [paymentProcessing, setPaymentProcessing] = useState(false);
const stripe = useStripe();
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
useEffect(() => {
if (!stripe) {
return;
}
const handleSubmit = async () => {
if (!stripe || !elements) return;
if (!paymentIntent) {
return;
}
setPaymentProcessing(true);
stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => {
switch (paymentIntent.status) {
case "succeeded":
setMessage("Payment succeeded!");
break;
case "processing":
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;
}
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${CAL_URL}/settings/profile`,
},
redirect: "if_required",
});
}, [stripe]);
if (error) {
console.log("🚀 ~ file: PurchaseNewTeam.tsx ~ line 71 ~ handleSubmit ~ error", error);
} else {
createTeamMutation.mutate(newTeamData);
}
};
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 />
<Button
className="mt-4 w-full justify-center"
loading={paymentProcessing}
onClick={() => handleSubmit()}>
Pay ${teamPrices[billingFrequency as keyof typeof teamPrices]}
Pay ${total} / {billingFrequency}
</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 Head from "next/head";
import { useRouter } from "next/router";
@ -45,6 +45,7 @@ const CreateNewTeamPage = () => {
slug: "",
logo: "",
members: [],
billingFrequency: "monthly",
});
const [clientSecret, setClientSecret] = useState("");
const [paymentIntent, setPaymentIntent] = useState("");
@ -82,25 +83,19 @@ const CreateNewTeamPage = () => {
const currentStepIndex = steps.indexOf(currentStep);
const createPaymentIntentMutation = trpc.useMutation(["viewer.teams.mutatePaymentIntent"], {
const createPaymentIntentMutation = trpc.useMutation(["viewer.teams.createPaymentIntent"], {
onSuccess: (data) => {
console.log("🚀 ~ file: [[...step]].tsx ~ line 86 ~ CreateNewTeamPage ~ data", data);
setClientSecret(data.client_secret);
setPaymentIntent(data.id);
setClientSecret(data.clientSecret);
goToIndex(2);
},
});
const getTeamPricesQuery = trpc.useQuery(["viewer.teams.getTeamPrices"], {
onSuccess: (data) => {
setTeamPrices(data);
console.log("🚀 ~ file: [[...step]].tsx ~ line 95 ~ CreateNewTeamPage ~ data", data);
},
});
useEffect(() => {
createPaymentIntentMutation.mutate({ amount: 1500 });
}, []);
return (
<div
className="dark:bg-brand dark:text-brand-contrast min-h-screen text-black"
@ -133,7 +128,6 @@ const CreateNewTeamPage = () => {
<CreateNewTeam
nextStep={(values: NewTeamFormValues) => {
setNewTeamData({ ...values, members: [] });
localStorage.setItem("newTeamValues", JSON.stringify(values));
goToIndex(1);
}}
/>
@ -146,17 +140,21 @@ const CreateNewTeamPage = () => {
{currentStep === "add-team-members" && (
<AddNewTeamMembers
teamPrices={teamPrices}
nextStep={(values: PendingMember[]) => {
console.log("🚀 ~ file: [[...step]].tsx ~ line 144 ~ CreateNewTeamPage ~ values", values);
setNewTeamData({ ...newTeamData, members: [...values] });
// localStorage.removeItem("newTeamValues");
// purchaseTeamMutation.mutate({
// ...newTeamData,
// members: [...values],
// language: i18n.language,
// });
createPaymentIntentMutation.mutate({ amount: values.length * 15 * 100 });
goToIndex(2);
nextStep={(values: {
members: PendingMember[];
billingFrequency: "monthly" | "yearly";
}) => {
console.log("🚀 ~ file: [[...step]].tsx ~ line 148 ~ CreateNewTeamPage ~ values", values);
createPaymentIntentMutation.mutate({
teamName: newTeamData.name,
billingFrequency: values.billingFrequency,
seats: values.members.length,
});
setNewTeamData({
...newTeamData,
members: [...values.members],
billingFrequency: values.billingFrequency,
});
}}
/>
)}
@ -164,9 +162,13 @@ const CreateNewTeamPage = () => {
{currentStep === "purchase-new-team" && (
<Elements stripe={stripe} options={{ clientSecret }}>
<PurchaseNewTeam
newTeamData={newTeamData}
paymentIntent={paymentIntent}
clientSecret={clientSecret}
total={
newTeamData.members.length *
teamPrices[newTeamData.billingFrequency as keyof typeof teamPrices]
}
newTeamData={newTeamData}
/>
</Elements>
)}

View File

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

View File

@ -15,18 +15,19 @@ export const createMember = async ({
pendingMember,
language,
teamSubscriptionActive,
}): {
}: {
teamId: number;
teamName: string;
inviter: string;
pendingMember: PendingMember;
language: string;
teamSubscriptionActive?: boolean;
} => {
if (pendingMember.username) {
}) => {
if (pendingMember.username & pendingMember.id) {
await prisma.membership.create({
data: {
teamId,
userId: pendingMember.userId,
userId: pendingMember.id,
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 }) => {
const { teamId, seats, email } = input;
return await stripe.checkout.sessions.create({

View File

@ -18,4 +18,5 @@ export interface PendingMember {
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 {
deleteTeamFromStripe,
purchaseTeamSubscription,
createTeamCustomer,
createTeamSubscription,
createPaymentIntent,
getTeamPricing,
updatePaymentIntent,
@ -688,23 +690,69 @@ export const viewerTeamsRouter = createProtectedRouter()
return teamPrices;
},
})
.mutation("mutatePaymentIntent", {
.mutation("createPaymentIntent", {
input: z.object({
amount: z.number(),
paymentIntentId: z.string().optional(),
teamName: z.string(),
billingFrequency: z.string(),
seats: z.number(),
}),
async resolve({ ctx, input }) {
const { amount, paymentIntentId } = input;
try {
// If there is no payment intent, then create one
if (!paymentIntentId) {
const paymentIntent = await createPaymentIntent({ amount, receiptEmail: ctx.user.email });
return paymentIntent;
}
console.log("🚀 ~ file: teams.tsx ~ line 699 ~ resolve ~ input", input);
// First create the customer
const customer = await createTeamCustomer(input.teamName, ctx.user.email);
await updatePaymentIntent({ amount, paymentIntentId });
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: e.message });
// Create the subscription for the team
const subscription = await createTeamSubscription(customer.id, input.billingFrequency, input.seats);
// 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;
},
});