diff --git a/prisma/migrations/20221216182001_payments/migration.sql b/prisma/migrations/20221216182001_payments/migration.sql new file mode 100644 index 0000000..296ddd7 --- /dev/null +++ b/prisma/migrations/20221216182001_payments/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "payments" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "status" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "projectId" TEXT, + + CONSTRAINT "payments_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "payments" ADD CONSTRAINT "payments_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20221216182142_stripe_session/migration.sql b/prisma/migrations/20221216182142_stripe_session/migration.sql new file mode 100644 index 0000000..2847a74 --- /dev/null +++ b/prisma/migrations/20221216182142_stripe_session/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `stripeSessionId` to the `payments` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "payments" ADD COLUMN "stripeSessionId" TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee76d8f..0acbfce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,7 +60,7 @@ model VerificationToken { } model Project { - id String @id @default(cuid()) + id String @id @default(cuid()) name String replicateModelId String? stripePaymentId String? @@ -70,12 +70,13 @@ model Project { instanceClass String imageUrls String[] zipImageUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - User User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + User User? @relation(fields: [userId], references: [id]) userId String? shots Shot[] - credits Int @default(100) + credits Int @default(100) + Payment Payment[] } model Shot { @@ -91,3 +92,15 @@ model Shot { bookmarked Boolean? @default(false) blurhash String? } + +model Payment { + id String @id @default(cuid()) + type String + status String + stripeSessionId String + createdAt DateTime @default(now()) + Project Project? @relation(fields: [projectId], references: [id]) + projectId String? + + @@map("payments") +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index c7699d6..53866fe 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,7 +4,6 @@ import { HStack, Icon, IconButton, - Popover, Text, Tooltip, } from "@chakra-ui/react"; diff --git a/src/components/projects/FormPayment.tsx b/src/components/projects/FormPayment.tsx index 8ed1685..8172789 100644 --- a/src/components/projects/FormPayment.tsx +++ b/src/components/projects/FormPayment.tsx @@ -29,7 +29,8 @@ const FormPayment = ({ useQuery( "check-payment", - () => axios.get(`/api/checkout/check/${query.ppi}/${query.session_id}`), + () => + axios.get(`/api/checkout/check/${query.ppi}/${query.session_id}/studio`), { cacheTime: 0, refetchInterval: 10, diff --git a/src/components/projects/shot/BuyShotButton.tsx b/src/components/projects/shot/BuyShotButton.tsx new file mode 100644 index 0000000..84f8a20 --- /dev/null +++ b/src/components/projects/shot/BuyShotButton.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from "react"; +import { + Menu, + MenuButton, + MenuList, + MenuItem, + Button, + HStack, + Text, +} from "@chakra-ui/react"; +import { useRouter } from "next/router"; +import { useQuery } from "react-query"; +import axios from "axios"; +import { BsChevronDown } from "react-icons/bs"; +import { IoIosFlash } from "react-icons/io"; + +const BuyShotButton = ({ + credits, + onPaymentSuccess, +}: { + credits: number; + onPaymentSuccess: (credits: number) => void; +}) => { + const { push, query } = useRouter(); + const [waitingPayment, setWaitingPayment] = useState(false); + + const { isLoading } = useQuery( + "check-shot-payment", + () => + axios.get(`/api/checkout/check/${query.ppi}/${query.session_id}/shot`), + { + cacheTime: 0, + refetchInterval: 4, + retry: 0, + enabled: waitingPayment, + onSuccess: (response) => { + onPaymentSuccess(response.data.credits); + }, + onSettled: () => { + setWaitingPayment(false); + }, + } + ); + + useEffect(() => { + setWaitingPayment(query.ppi === query.id); + }, [query]); + + const handleShotPayment = (quantity: number) => { + push(`/api/checkout/shots?quantity=${quantity}&ppi=${query.id}`); + }; + + return ( + + } + isLoading={isLoading} + size="xs" + shadow="none" + variant="brand" + as={Button} + > + + + {credits === 0 ? ( + Buy more shots + ) : ( + + {credits} Shot{credits > 1 && "s"} left + + )} + + + + { + handleShotPayment(100); + }} + > + Add 100 shots + + { + handleShotPayment(200); + }} + > + Add 200 shots + + { + handleShotPayment(300); + }} + > + Add 300 shots + + + + ); +}; + +export default BuyShotButton; diff --git a/src/pages/api/checkout/check/[ppi]/[sessionId]/shot.ts b/src/pages/api/checkout/check/[ppi]/[sessionId]/shot.ts new file mode 100644 index 0000000..e0ca456 --- /dev/null +++ b/src/pages/api/checkout/check/[ppi]/[sessionId]/shot.ts @@ -0,0 +1,56 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import Stripe from "stripe"; +import db from "@/core/db"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2022-11-15", +}); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const sessionId = req.query.sessionId as string; + const ppi = req.query.ppi as string; + + const session = await stripe.checkout.sessions.retrieve(sessionId); + + const payments = await db.payment.findMany({ + where: { + stripeSessionId: sessionId, + projectId: ppi, + status: "paid", + type: "credits", + }, + }); + + if (payments.length > 0) { + return res + .status(400) + .json({ success: false, error: "payment_already_processed" }); + } + + if ( + session.payment_status === "paid" && + session.metadata?.projectId === ppi + ) { + const quantity = Number(session.metadata?.quantity); + const project = await db.project.update({ + where: { id: ppi }, + data: { credits: { increment: quantity } }, + }); + + await db.payment.create({ + data: { + status: "paid", + projectId: ppi, + type: "credits", + stripeSessionId: sessionId, + }, + }); + + return res.status(200).json({ success: true, credits: project.credits }); + } + + return res.status(400).json({ success: false }); +} diff --git a/src/pages/api/checkout/check/[ppi]/[sessionId]/index.ts b/src/pages/api/checkout/check/[ppi]/[sessionId]/studio.ts similarity index 100% rename from src/pages/api/checkout/check/[ppi]/[sessionId]/index.ts rename to src/pages/api/checkout/check/[ppi]/[sessionId]/studio.ts diff --git a/src/pages/api/checkout/session.ts b/src/pages/api/checkout/session.ts index 65cda74..ef809f7 100644 --- a/src/pages/api/checkout/session.ts +++ b/src/pages/api/checkout/session.ts @@ -36,6 +36,4 @@ export default async function handler( } catch (err: any) { return res.status(400).json(err.message); } - - return res.status(400).json({}); } diff --git a/src/pages/api/checkout/shots.ts b/src/pages/api/checkout/shots.ts new file mode 100644 index 0000000..b172f18 --- /dev/null +++ b/src/pages/api/checkout/shots.ts @@ -0,0 +1,49 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import Stripe from "stripe"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2022-11-15", +}); + +const PRICES = { 100: 400, 200: 700, 300: 900 }; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const quantity = Number(req.query.quantity); + const ppi = req.query.ppi; + + if (quantity !== 100 && quantity !== 200 && quantity !== 300) { + return res.status(400).json("invalid_quantity"); + } + + try { + const session = await stripe.checkout.sessions.create({ + allow_promotion_codes: true, + metadata: { + projectId: req.query.ppi as string, + quantity, + }, + line_items: [ + { + price_data: { + currency: "usd", + unit_amount: PRICES[quantity], + product_data: { + name: `⚡️ Refill +${quantity} shots`, + }, + }, + quantity: 1, + }, + ], + mode: "payment", + success_url: `${process.env.NEXTAUTH_URL}/studio/${ppi}/?session_id={CHECKOUT_SESSION_ID}&ppi=${ppi}`, + cancel_url: `${process.env.NEXTAUTH_URL}/studio/${ppi}`, + }); + + return res.redirect(303, session.url!); + } catch (err: any) { + return res.status(400).json(err.message); + } +} diff --git a/src/pages/api/projects/[id]/predictions/index.ts b/src/pages/api/projects/[id]/predictions/index.ts index d478b56..5e23278 100644 --- a/src/pages/api/projects/[id]/predictions/index.ts +++ b/src/pages/api/projects/[id]/predictions/index.ts @@ -23,7 +23,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { data } = await replicateClient.post( `https://api.replicate.com/v1/predictions`, { - input: { prompt }, + input: { + prompt, + }, version: project.modelVersionId, } ); diff --git a/src/pages/studio/[id].tsx b/src/pages/studio/[id].tsx index d8769e3..56522bc 100644 --- a/src/pages/studio/[id].tsx +++ b/src/pages/studio/[id].tsx @@ -1,32 +1,29 @@ import PageContainer from "@/components/layout/PageContainer"; +import BuyShotButton from "@/components/projects/shot/BuyShotButton"; import ShotCard from "@/components/projects/shot/ShotCard"; import db from "@/core/db"; +import { getRefinedInstanceClass } from "@/core/utils/predictions"; import { - Badge, Box, Button, - Divider, Flex, Icon, - Text, - Textarea, Link as ChakraLink, - VStack, SimpleGrid, + Text, + Textarea, } from "@chakra-ui/react"; import { Project, Shot } from "@prisma/client"; import axios from "axios"; import { GetServerSidePropsContext } from "next"; import { getSession } from "next-auth/react"; +import Link from "next/link"; import { useRef, useState } from "react"; -import { useMutation, useQuery } from "react-query"; -import superjson from "superjson"; +import { BsLightbulb } from "react-icons/bs"; import { FaMagic } from "react-icons/fa"; -import { formatRelative } from "date-fns"; -import Link from "next/link"; import { HiArrowLeft } from "react-icons/hi"; -import { BsLightbulb } from "react-icons/bs"; -import { getRefinedInstanceClass } from "@/core/utils/predictions"; +import { useMutation, useQuery } from "react-query"; +import superjson from "superjson"; export type ProjectWithShots = Project & { shots: Shot[]; @@ -108,17 +105,17 @@ const StudioPage = ({ project }: IStudioPageProps) => { > Studio {project.instanceName}{" "} - {shotCredits} shots left - - - {formatRelative(new Date(project.createdAt), new Date())} + { + setShotCredits(credits); + }} + /> - { e.preventDefault(); @@ -129,6 +126,7 @@ const StudioPage = ({ project }: IStudioPageProps) => { width="100%" >