diff --git a/client/src/components/Artist/ArtistSupportBox.tsx b/client/src/components/Artist/ArtistSupportBox.tsx index 5910751f6..f72744e52 100644 --- a/client/src/components/Artist/ArtistSupportBox.tsx +++ b/client/src/components/Artist/ArtistSupportBox.tsx @@ -172,7 +172,7 @@ const ArtistSupportBox: React.FC<{ /> )} - {(isSubscribedToTier || ownedByUser || isSubscribedToArtist) && ( + {(isSubscribedToTier || isSubscribedToArtist) && ( = ({ purchase }) => { + const { i18n, t } = useTranslation("translation", { + keyPrefix: "fulfillment", + }); + + const [status, setStatus] = React.useState(purchase.fulfillmentStatus); + + const updateStatus = React.useCallback( + (e: React.ChangeEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + api.put(`manage/purchases/${purchase.id}`, { + fulfillmentStatus: e.target.value, + }); + setStatus(e.target.value as MerchPurchase["fulfillmentStatus"]); + } catch (e) {} + }, + [purchase.id] + ); + return ( +
+ +

{purchase.user.name}

+
+ {" "} + {purchase.merch.title} +
+
+ {purchase.quantity} +
+
+ + {formatDate({ + date: purchase.updatedAt, + i18n, + options: { dateStyle: "short" }, + })} +
+
+ +
+ +

+ {purchase.user.name}
+ {purchase.shippingAddress.line1 && ( + <> + {purchase.shippingAddress.line1} +
+ + )} + {purchase.shippingAddress.line1 && ( + <> + {purchase.shippingAddress.line1} +
+ + )} + {purchase.shippingAddress.city && ( + <> + {purchase.shippingAddress.city} +
+ + )} + {purchase.shippingAddress.state}, + {purchase.shippingAddress.postal_code} +

+
+
+ +
+ +
+

+ + + + + + +
+
+
+
+ ); +}; + +export default CustomerPopUp; diff --git a/client/src/components/FulFillment/Fulfillment.tsx b/client/src/components/FulFillment/Fulfillment.tsx new file mode 100644 index 000000000..8cabb7e0f --- /dev/null +++ b/client/src/components/FulFillment/Fulfillment.tsx @@ -0,0 +1,80 @@ +import { css } from "@emotion/css"; +import Table from "components/common/Table"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import api from "services/api"; +import FulfillmentRow from "./FulfillmentRow"; + +export const Fulfillment: React.FC = () => { + const { t } = useTranslation("translation", { + keyPrefix: "fulfillment", + }); + const [results, setResults] = React.useState([]); + const [total, setTotal] = React.useState(); + + const callback = React.useCallback(async (search?: URLSearchParams) => { + if (search) { + search.append("orderBy", "createdAt"); + } + const { results, total: totalResults } = await api.getMany( + `manage/purchases?${search?.toString()}` + ); + setTotal(totalResults); + setResults(results); + }, []); + + React.useEffect(() => { + callback(); + }, [callback]); + + console.log("results", results); + + return ( +
+

Orders & Fulfillment

+

Total results: {total}

+ {results.length > 0 && ( +
+ + + + + + + + + + + + + + + {results.map((purchase, index) => ( + + ))} + +
+ {t("artist")}{t("merchItem")}{t("customer")}{t("email")}{t("quantity")}{t("fulfillmentStatus")}{t("orderDate ")}{t("lastUpdated")}
+
+ )} +
+ ); +}; + +export default Fulfillment; diff --git a/client/src/components/FulFillment/FulfillmentRow.tsx b/client/src/components/FulFillment/FulfillmentRow.tsx new file mode 100644 index 000000000..163e250a5 --- /dev/null +++ b/client/src/components/FulFillment/FulfillmentRow.tsx @@ -0,0 +1,72 @@ +import Button from "components/common/Button"; +import Modal from "components/common/Modal"; +import { SelectEl } from "components/common/Select"; +import { formatDate } from "components/TrackGroup/ReleaseDate"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { FaEye } from "react-icons/fa"; +import api from "services/api"; +import CustomerPopUp from "./CustomerPopUp"; +import { css } from "@emotion/css"; + +const statusMap = { + SHIPPED: "shipped", + NO_PROGRESS: "noProgress", + STARTED: "started", + COMPLETED: "completed", +}; + +const FulfillmentRow: React.FC<{ purchase: MerchPurchase; index: number }> = ({ + purchase, + index, +}) => { + const { i18n, t } = useTranslation("translation", { + keyPrefix: "fulfillment", + }); + const [isEditing, setIsEditing] = React.useState(false); + + return ( + <> + setIsEditing(true)} + className={css` + cursor: pointer; + `} + > + + )} + {user?.isAdmin && ( +
  • + +
  • + )}
  • diff --git a/client/src/components/Merch/BuyMerchItem.tsx b/client/src/components/Merch/BuyMerchItem.tsx index b6b55c63c..9c80ef7e5 100644 --- a/client/src/components/Merch/BuyMerchItem.tsx +++ b/client/src/components/Merch/BuyMerchItem.tsx @@ -13,10 +13,14 @@ import FormComponent from "components/common/FormComponent"; import React from "react"; import { css } from "@emotion/css"; import api from "services/api"; +import { SelectEl } from "components/common/Select"; +import { moneyDisplay } from "components/common/Money"; type FormData = { quantity: number; price: number; + merchOptionIds: string[]; + shippingDestinationId: string; }; function BuyMerchItem() { @@ -40,12 +44,15 @@ function BuyMerchItem() { const minPrice = (merch?.minPrice ?? 0) / 100; const methods = useForm({ - defaultValues: { price: minPrice, quantity: 1 }, + defaultValues: { + price: minPrice, + quantity: 1, + merchOptionIds: [], + }, }); const onSubmit = React.useCallback( async (data: FormData) => { - console.log(data); try { if (merch) { setIsLoadingStripe(true); @@ -54,6 +61,8 @@ function BuyMerchItem() { { price: data.price ? Number(data.price) * 100 : undefined, quantity: data.quantity, + merchOptionIds: data.merchOptionIds, + shippingDestinationId: data.shippingDestinationId, } ); window.location.assign(response.redirectUrl); @@ -83,10 +92,19 @@ function BuyMerchItem() { return null; } - console.log("errors", methods.formState.errors); + const onlyOneDestination = merch.shippingDestinations.length === 1; + const defaultOption = onlyOneDestination + ? t("everywhere") + : t("everywhereElse"); return ( - +

    {t("buy")}

    {t("supportThisArtistByPurchasing")}

    @@ -105,7 +123,7 @@ function BuyMerchItem() { /> - + - diff --git a/client/src/components/Merch/MerchView.tsx b/client/src/components/Merch/MerchView.tsx index 53b8ef764..af7615ea8 100644 --- a/client/src/components/Merch/MerchView.tsx +++ b/client/src/components/Merch/MerchView.tsx @@ -14,7 +14,6 @@ import WidthContainer from "components/common/WidthContainer"; import { ItemViewTitle } from "../TrackGroup/TrackGroupTitle"; import SupportArtistPopUp from "components/common/SupportArtistPopUp"; -import { useAuthContext } from "state/AuthContext"; import { useQuery } from "@tanstack/react-query"; import { queryArtist, queryMerch, queryUserStripeStatus } from "queries"; import { @@ -22,6 +21,10 @@ import { ImageWrapper, } from "components/TrackGroup/TrackGroup"; import BuyMerchItem from "./BuyMerchItem"; +import SpaceBetweenDiv from "components/common/SpaceBetweenDiv"; +import { ButtonLink } from "components/common/Button"; +import { getArtistManageMerchUrl } from "utils/artist"; +import { FaPen } from "react-icons/fa"; function TrackGroup() { const { t } = useTranslation("translation", { @@ -91,8 +94,17 @@ function TrackGroup() { } `} > - - + + + } + variant="dashed" + to={getArtistManageMerchUrl(artist.id, merch.id)} + > + {t("editMerch")} + +
    , + }; + }, + }, { path: "releases", async lazy() { diff --git a/client/src/translation/en.json b/client/src/translation/en.json index 739aefc8c..d1b0c2317 100644 --- a/client/src/translation/en.json +++ b/client/src/translation/en.json @@ -122,7 +122,7 @@ "addToCollection": "Add to collection", "addToCollectionDescription": "You'll add this free album to your collection.", "addAlbumToCollection": "Add {{ title }} to your collection", - "getDownloadLink": "Get download link email", + "getDownloadLink": "Get download lin k email", "email": "Email", "success": "Success", "nameYourPrice": "Name your price in {{ currency }}:", @@ -133,6 +133,37 @@ "justDownloadLoggedIn": "Want to just download the album? We'll email you a download link. You'll get added to the artist's mailing list.", "justDownloadNoUser": "Want to just download the album? Enter your email and we'll email you a download link. You'll get added to the artist's mailing list." }, + "fulfillment": { + "artist": "Artist", + "merchItem": "Merch item", + "customer": "Customer", + "email": "E-mail", + "fulfillmentStatus": "Fulfillment status", + "orderDate": "Ordered on", + "quantity": "Quantity", + "address": "Address", + "lastUpdated": "Last updated", + "noProgress": "No progress", + "started": "Started", + "shipped": "Shipped", + "completed": "Completed", + "purchaseDetails": "Purchase details", + "shippingAddress": "Shipping address:", + "itemPurchased": "Purchased" + }, + "merchDetails": { + "buy": "Buy", + "supportThisArtistByPurchasing": "Support this artist!", + "howMany": "How many do you want?", + "howMuch": "Pay {{ currency }}", + "editMerch": "Edit merch", + "shipsTo": "Ships to {{ destinations }}", + "doesNotExist": "The page you are looking for does not exist", + "everywhere": "Everywhere", + "everywhereElse": "Everywhere else", + "destinationCost": "Cost to ship here: {{costUnit}}", + "supportedShippingDestinations": "This artist ships to, choose the one that best matches you " + }, "artist": { "merch": "Merch", "Email": "Email", @@ -292,8 +323,11 @@ "merchTitle": "What are you selling?", "merchDescription": "How would you describe it?", "price": "Price", - "quanity": "How many do you have?", + "quantity": "How many do you have?", + "everywhere": "Everywhere", + "everywhereElse": "Everywhere else", "saveMerch": "Save merch details", + "merchUpdated": "Merch updated", "isPublic": "Should this merch item be publicly listed?", "costUnit": "Cost per unit", "costExtraUnit": "Cost per additional unit", diff --git a/client/src/types/index.d.ts b/client/src/types/index.d.ts index 361a7f030..2271ae7a7 100644 --- a/client/src/types/index.d.ts +++ b/client/src/types/index.d.ts @@ -244,10 +244,12 @@ interface MerchOption { name: string; quantityRemaining: number; sku: string; + id: string; } interface MerchOptionType { optionName: string; + id: string; options: MerchOption[]; } @@ -268,3 +270,23 @@ interface Merch { shippingDestinations: ShippingDestination[]; optionTypes: MerchOptionType[]; } + +interface MerchPurchase { + merchId: string; + id: string; + merch: Merch; + user: User; + userId: number; + createdAt: string; + updatedAt: string; + quantity: number; + fulfillmentStatus: "NO_PROGRESS" | "STARTED" | "SHIPPED" | "COMPLETED"; + shippingAddress: { + line1: string; + line2: string; + postal_code: string; + city?: string; + state?: string; + country?: string; + }; +} diff --git a/client/src/utils/artist.ts b/client/src/utils/artist.ts index 86ca63d43..48fe72098 100644 --- a/client/src/utils/artist.ts +++ b/client/src/utils/artist.ts @@ -26,6 +26,10 @@ export const getArtistManageUrl = (artistId: number) => { return `/manage/artists/${artistId}`; }; +export const getArtistManageMerchUrl = (artistId: number, merchId: string) => { + return `/manage/artists/${artistId}/merch/${merchId}`; +}; + export const getTrackGroupWidget = (trackGroupId: number) => { return `${import.meta.env.VITE_CLIENT_DOMAIN}/widget/trackGroup/${trackGroupId}`; }; diff --git a/emails/artist-merch-purchase-receipt/html.pug b/emails/artist-merch-purchase-receipt/html.pug new file mode 100644 index 000000000..f3fe09404 --- /dev/null +++ b/emails/artist-merch-purchase-receipt/html.pug @@ -0,0 +1,21 @@ +doctype html +html + head + block head + meta(charset="utf-8") + meta(name="viewport", content="width=device-width") + meta(http-equiv="X-UA-Compatible", content="IE=edge") + meta(name="x-apple-disable-message-reformatting") + style + include ../style.css + body.sans-serif + p Wow! + p Thank you for supporting #{merchPurchase.merch.artist.name} with your purchase. + + ul + each purchase in purchases + li #{purchase.merch.title} x#{purchase.quantity} + | #{purchase.currencyPaid.toUpperCase()} #{(purchase.amountPaid / 100).toFixed(2)} + + p The artist will start fulfilling your order and be in touch. + p Enjoy! diff --git a/emails/artist-merch-purchase-receipt/subject.pug b/emails/artist-merch-purchase-receipt/subject.pug new file mode 100644 index 000000000..de6317982 --- /dev/null +++ b/emails/artist-merch-purchase-receipt/subject.pug @@ -0,0 +1 @@ += `Mirlo: Your purchase is inside!` diff --git a/emails/artist-merch-purchase-receipt/text.pug b/emails/artist-merch-purchase-receipt/text.pug new file mode 100644 index 000000000..b09f6d199 --- /dev/null +++ b/emails/artist-merch-purchase-receipt/text.pug @@ -0,0 +1,10 @@ +| Wow! +| +| Thank you for supporting #{merchPurchase.merch.artist.name} with your purchase. +| +each purchase in purchases + | #{purchase.merch.title} x#{purchase.quantity} + | #{purchase.currencyPaid.toUpperCase()} #{(purchase.amountPaid / 100).toFixed(2)} + +| The artist will start fulfilling your order and be in touch. +| Enjoy! \ No newline at end of file diff --git a/emails/tell-artist-about-merch-purchase/html.pug b/emails/tell-artist-about-merch-purchase/html.pug new file mode 100644 index 000000000..0eb52bfd5 --- /dev/null +++ b/emails/tell-artist-about-merch-purchase/html.pug @@ -0,0 +1,28 @@ +doctype html +html + head + block head + meta(charset="utf-8") + meta(name="viewport", content="width=device-width") + meta(http-equiv="X-UA-Compatible", content="IE=edge") + meta(name="x-apple-disable-message-reformatting") + style + include ../style.css + body.sans-serif + div + div.mw600 + p + | Hello #{artist.user.name}! + p + | Someone (#{email}) bought something from you!. + ul.mw600 + each purchase in purchases + li #{purchase.merch.title} x#{purchase.quantity} + | #{purchase.currencyPaid.toUpperCase()} #{(purchase.amountPaid / 100).toFixed(2)} + | Mirlo's cut: (#{purchase.currencyPaid.toUpperCase()} #{purchase.platformCut.toFixed(2)}) + strong Total: #{purchase.currencyPaid.toUpperCase()} #{(purchase.artistCut).toFixed(2)} + + p To learn more about this order, please see your fulfillment dashboard. + p The final money will be available via your Stripe dashboard + p If you have any questions please reach out to hi@mirlo.space + diff --git a/emails/tell-artist-about-merch-purchase/subject.pug b/emails/tell-artist-about-merch-purchase/subject.pug new file mode 100644 index 000000000..632cf7a84 --- /dev/null +++ b/emails/tell-artist-about-merch-purchase/subject.pug @@ -0,0 +1 @@ += `Mirlo: Someone bought ${trackGroup.title}!` diff --git a/emails/tell-artist-about-merch-purchase/text.pug b/emails/tell-artist-about-merch-purchase/text.pug new file mode 100644 index 000000000..20714a8b8 --- /dev/null +++ b/emails/tell-artist-about-merch-purchase/text.pug @@ -0,0 +1,11 @@ +| Yay! +| +| Someone (#{email}) bought a digital copy of #{trackGroup.title}. +| +| #{trackGroup.title} +| #{pricePaid} #{purchase.currencyPaid} +| Mirlo percentage: #{trackGroup.platformPercent} +| Final: #{pricePaid - platformCut} #{purchase.currencyPaid} +| +| The final money will be available via your Stripe dashboard +| Enjoy! \ No newline at end of file diff --git a/prisma/migrations/20240913183626_add_merch_purchase_quantity/migration.sql b/prisma/migrations/20240913183626_add_merch_purchase_quantity/migration.sql new file mode 100644 index 000000000..cc0939b1d --- /dev/null +++ b/prisma/migrations/20240913183626_add_merch_purchase_quantity/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `quantity` to the `MerchPurchase` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "MerchPurchase" ADD COLUMN "quantity" INTEGER NOT NULL; diff --git a/prisma/migrations/20240916151805_add_created_at_for_purchase/migration.sql b/prisma/migrations/20240916151805_add_created_at_for_purchase/migration.sql new file mode 100644 index 000000000..49d8f6454 --- /dev/null +++ b/prisma/migrations/20240916151805_add_created_at_for_purchase/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "MerchPurchase" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20240916194208_add_stripe_product_key_to_merch_option/migration.sql b/prisma/migrations/20240916194208_add_stripe_product_key_to_merch_option/migration.sql new file mode 100644 index 000000000..918c243ac --- /dev/null +++ b/prisma/migrations/20240916194208_add_stripe_product_key_to_merch_option/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "MerchOption" ADD COLUMN "stripeProductKey" TEXT; diff --git a/prisma/migrations/20240916194326_remove_stripe_product_key/migration.sql b/prisma/migrations/20240916194326_remove_stripe_product_key/migration.sql new file mode 100644 index 000000000..7b52517ce --- /dev/null +++ b/prisma/migrations/20240916194326_remove_stripe_product_key/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `stripeProductKey` on the `MerchOption` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "MerchOption" DROP COLUMN "stripeProductKey"; diff --git a/prisma/migrations/20240917132821_add_relation_between_merchoptiokn_and_merchpurchase/migration.sql b/prisma/migrations/20240917132821_add_relation_between_merchoptiokn_and_merchpurchase/migration.sql new file mode 100644 index 000000000..5281ae90f --- /dev/null +++ b/prisma/migrations/20240917132821_add_relation_between_merchoptiokn_and_merchpurchase/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "_MerchOptionToMerchPurchase" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_MerchOptionToMerchPurchase_AB_unique" ON "_MerchOptionToMerchPurchase"("A", "B"); + +-- CreateIndex +CREATE INDEX "_MerchOptionToMerchPurchase_B_index" ON "_MerchOptionToMerchPurchase"("B"); + +-- AddForeignKey +ALTER TABLE "_MerchOptionToMerchPurchase" ADD CONSTRAINT "_MerchOptionToMerchPurchase_A_fkey" FOREIGN KEY ("A") REFERENCES "MerchOption"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_MerchOptionToMerchPurchase" ADD CONSTRAINT "_MerchOptionToMerchPurchase_B_fkey" FOREIGN KEY ("B") REFERENCES "MerchPurchase"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7db17a49e..843b53a7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -326,20 +326,25 @@ model MerchOption { name String // eg. "small" quantityRemaining Int? sku String? + purchases MerchPurchase[] } model MerchPurchase { id String @id @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt merch Merch @relation(fields: [merchId], references: [id]) merchId String @db.Uuid currencyPaid String amountPaid Int // in cents stripeTransactionKey String? userId Int + quantity Int user User @relation(fields: [userId], references: [id]) shippingAddress Json? billingAddress Json? fulfillmentStatus FulfillmentStatus + options MerchOption[] } enum FulfillmentStatus { diff --git a/src/auth/passport.ts b/src/auth/passport.ts index 44054fb79..b1f5c4587 100644 --- a/src/auth/passport.ts +++ b/src/auth/passport.ts @@ -10,6 +10,7 @@ import logger from "../logger"; import { AppError } from "../utils/error"; import { doesMerchBelongToUser, + doesMerchPurchaseBelongToUser, doesTrackGroupBelongToUser, } from "../utils/ownership"; @@ -204,6 +205,32 @@ export const merchBelongsToLoggedInUser = async ( return next(); }; +export const merchPurchaseBelongsToLoggedInUser = async ( + req: Request, + _res: Response, + next: NextFunction +) => { + const { purchaseId } = req.params as unknown as { + purchaseId: string; + }; + + const loggedInUser = req.user as User | undefined; + + try { + if (!loggedInUser) { + throw new AppError({ + description: "Not logged in user", + httpCode: 401, + }); + } else { + await doesMerchPurchaseBelongToUser(purchaseId, loggedInUser); + } + } catch (e) { + return next(e); + } + return next(); +}; + export const trackBelongsToLoggedInUser = async ( req: Request, _res: Response, diff --git a/src/index.ts b/src/index.ts index f95d2c954..70fc88e03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,6 +125,8 @@ const routes = [ "manage/merch/{merchId}/image", "manage/merch/{merchId}/destinations", "manage/merch/{merchId}/optionTypes", + "manage/purchases", + "manage/purchases/{purchaseId}", "manage/tracks", "manage/tracks/{trackId}/audio", "manage/tracks/{trackId}", diff --git a/src/routers/v1/api-doc.ts b/src/routers/v1/api-doc.ts index 3c9e6be2c..9ba57c8a7 100644 --- a/src/routers/v1/api-doc.ts +++ b/src/routers/v1/api-doc.ts @@ -26,6 +26,10 @@ const apiDoc = { }, }, }, + MerchPurchase: { + type: "object", + required: [], + }, TrackGroupPurchase: { type: "object", required: ["userId", "trackGroupId"], diff --git a/src/routers/v1/manage/artists/index.ts b/src/routers/v1/manage/artists/index.ts index 4971d067f..996b0a0b2 100644 --- a/src/routers/v1/manage/artists/index.ts +++ b/src/routers/v1/manage/artists/index.ts @@ -29,6 +29,7 @@ const forbiddenNames = [ "username", "you", "guest", + "fulfillment", "account", "administrator", "system", diff --git a/src/routers/v1/manage/artists/{artistId}/index.ts b/src/routers/v1/manage/artists/{artistId}/index.ts index 4f8331625..c1228cfa2 100644 --- a/src/routers/v1/manage/artists/{artistId}/index.ts +++ b/src/routers/v1/manage/artists/{artistId}/index.ts @@ -13,7 +13,6 @@ import { singleInclude, } from "../../../../../utils/artist"; import slugify from "slugify"; -import { AppError } from "../../../../../utils/error"; type Params = { artistId: string; diff --git a/src/routers/v1/manage/purchases/index.ts b/src/routers/v1/manage/purchases/index.ts new file mode 100644 index 000000000..08eddb773 --- /dev/null +++ b/src/routers/v1/manage/purchases/index.ts @@ -0,0 +1,51 @@ +import { NextFunction, Request, Response } from "express"; +import { userAuthenticated } from "../../../../auth/passport"; + +import prisma from "@mirlo/prisma"; +import { AppError } from "../../../../utils/error"; +import { processSingleMerch } from "../../../../utils/merch"; +import { User } from "@mirlo/prisma/client"; + +type Params = { + merchId: string; +}; + +export default function () { + const operations = { + GET: [userAuthenticated, GET], + }; + + async function GET(req: Request, res: Response, next: NextFunction) { + const user = req.user as User; + + try { + const total = await prisma.merchPurchase.count({ + where: { + merch: { artist: { userId: user.id } }, + }, + }); + const purchases = await prisma.merchPurchase.findMany({ + where: { + merch: { artist: { userId: user.id } }, + }, + include: { + merch: { + include: { + artist: true, + }, + }, + user: true, + }, + }); + + return res.status(200).json({ + results: purchases, + total, + }); + } catch (e) { + next(e); + } + } + + return operations; +} diff --git a/src/routers/v1/manage/purchases/{purchaseId}/index.ts b/src/routers/v1/manage/purchases/{purchaseId}/index.ts new file mode 100644 index 000000000..ea6531e94 --- /dev/null +++ b/src/routers/v1/manage/purchases/{purchaseId}/index.ts @@ -0,0 +1,135 @@ +import { NextFunction, Request, Response } from "express"; +import { + merchPurchaseBelongsToLoggedInUser, + userAuthenticated, +} from "../../../../../auth/passport"; +import prisma from "@mirlo/prisma"; +import { User } from "@mirlo/prisma/client"; + +type Params = { + purchaseId: string; +}; + +export default function () { + const operations = { + PUT: [userAuthenticated, merchPurchaseBelongsToLoggedInUser, PUT], + GET: [userAuthenticated, merchPurchaseBelongsToLoggedInUser, GET], + }; + + async function PUT(req: Request, res: Response, next: NextFunction) { + const { purchaseId } = req.params as unknown as Params; + const { fulfillmentStatus } = req.body; + try { + const updatedCount = await prisma.merchPurchase.updateMany({ + where: { + id: purchaseId, + }, + data: { + fulfillmentStatus, + }, + }); + + if (updatedCount) { + const artist = await prisma.merchPurchase.findFirst({ + where: { id: purchaseId }, + }); + res.json({ result: artist }); + } else { + res.json({ + error: "An unknown error occurred", + }); + } + } catch (error) { + next(error); + } + } + + PUT.apiDoc = { + summary: "Updates a merch purchase belonging to a user", + parameters: [ + { + in: "path", + name: "purchaseId", + required: true, + type: "string", + }, + { + in: "body", + name: "purchase", + schema: { + $ref: "#/definitions/MerchPurchase", + }, + }, + ], + responses: { + 200: { + description: "Updated merch purchase", + schema: { + $ref: "#/definitions/MerchPurchase", + }, + }, + default: { + description: "An error occurred", + schema: { + additionalProperties: true, + }, + }, + }, + }; + + async function GET(req: Request, res: Response, next: NextFunction) { + const { purchaseId } = req.params as unknown as Params; + + try { + const purchase = await prisma.merchPurchase.findFirst({ + where: { + id: purchaseId, + }, + include: { + merch: { include: { images: true, artist: true } }, + user: true, + }, + }); + + if (!purchase) { + return res.status(404).json({ + error: "Purchase not found", + }); + } else { + return res.json({ + result: purchase, + }); + } + } catch (e) { + next(e); + } + } + + GET.apiDoc = { + summary: "Returns merch purchase information that belongs to a user", + parameters: [ + { + in: "path", + name: "purchaseId", + required: true, + type: "string", + }, + ], + responses: { + 200: { + description: "An merchPurchase that matches the id", + schema: { + $ref: "#/definitions/MerchPurchase", + }, + }, + default: { + description: "An error occurred", + schema: { + additionalProperties: true, + }, + }, + }, + }; + + return operations; +} diff --git a/src/routers/v1/merch/{id}/index.ts b/src/routers/v1/merch/{id}/index.ts index baee73950..373d6cd37 100644 --- a/src/routers/v1/merch/{id}/index.ts +++ b/src/routers/v1/merch/{id}/index.ts @@ -22,10 +22,15 @@ export default function () { where: { isPublic: true, id, + shippingDestinations: { + some: {}, + }, }, include: { artist: true, images: true, + shippingDestinations: true, + optionTypes: { include: { options: true } }, }, }); diff --git a/src/routers/v1/merch/{id}/purchase.ts b/src/routers/v1/merch/{id}/purchase.ts index 0a7c7faaf..80d5791c2 100644 --- a/src/routers/v1/merch/{id}/purchase.ts +++ b/src/routers/v1/merch/{id}/purchase.ts @@ -23,11 +23,14 @@ export default function () { async function POST(req: Request, res: Response, next: NextFunction) { const { id: merchId } = req.params as unknown as Params; - let { price, email, quantity } = req.body as unknown as { - price?: string; // In cents - email?: string; - quantity?: number; - }; + let { price, email, quantity, merchOptionIds, shippingDestinationId } = + req.body as unknown as { + price?: string; // In cents + email?: string; + quantity?: number; + merchOptionIds: string[]; + shippingDestinationId: string; + }; const loggedInUser = req.user as User | undefined; try { @@ -54,6 +57,11 @@ export default function () { }, shippingDestinations: true, images: true, + optionTypes: { + include: { + options: true, + }, + }, }, }); @@ -64,6 +72,16 @@ export default function () { }); } + // Check if the options passed are possible + const finalOptionIds: string[] = []; + merch.optionTypes.forEach((ot) => { + ot.options.forEach((o) => { + if (merchOptionIds.includes(o.id)) { + finalOptionIds.push(o.id); + } + }); + }); + if (loggedInUser) { await subscribeUserToArtist(merch?.artist, loggedInUser); } @@ -90,6 +108,8 @@ export default function () { merch, quantity: quantity ?? 0, stripeAccountId, + shippingDestinationId, + options: { merchOptionIds: finalOptionIds }, }); res.status(200).json({ redirectUrl: session.url, diff --git a/src/utils/handleFinishedTransactions.ts b/src/utils/handleFinishedTransactions.ts index 919dc835c..507e62086 100644 --- a/src/utils/handleFinishedTransactions.ts +++ b/src/utils/handleFinishedTransactions.ts @@ -1,5 +1,6 @@ import Stripe from "stripe"; import prisma from "@mirlo/prisma"; +import { Prisma, MerchPurchase, MerchOption } from "@mirlo/prisma/client"; import { logger } from "../logger"; import sendMail from "../jobs/send-mail"; @@ -7,8 +8,12 @@ import { registerPurchase } from "./trackGroup"; import { registerSubscription } from "./subscriptionTier"; import { getSiteSettings } from "./settings"; import { Job } from "bullmq"; -import { calculateAppFee } from "./stripe"; +import { calculateAppFee, OPTION_JOINER } from "./stripe"; +const { STRIPE_KEY } = process.env; +const stripe = new Stripe(STRIPE_KEY ?? "", { + apiVersion: "2022-11-15", +}); export const handleTrackGroupPurchase = async ( userId: number, trackGroupId: number, @@ -168,35 +173,128 @@ export const handleArtistGift = async ( } }; +// FIXME: is it possible to refactor all checkout sessions to use line_items +// so that we can use the same email etc for everything? export const handleArtistMerchPurchase = async ( userId: number, - merchId: string, session?: Stripe.Checkout.Session ) => { try { - const createdMerchPurchase = await prisma.merchPurchase.create({ - data: { - userId, - merchId, - amountPaid: session?.amount_total ?? 0, - currencyPaid: session?.currency ?? "USD", - stripeTransactionKey: session?.id ?? null, - fulfillmentStatus: "NO_PROGRESS", - shippingAddress: session?.shipping_details, - billingAddress: session?.customer_details?.address, - }, - }); + const purchases = ( + await Promise.all( + session?.line_items?.data.map(async (item) => { + const stripeProduct = item.price?.product; + let merchProduct; + let stripeProductId = + typeof stripeProduct === "string" + ? stripeProduct + : stripeProduct?.id; - const merchPurchase = await prisma.merchPurchase.findFirst({ - where: { - id: createdMerchPurchase.id, - }, - include: { - merch: { - include: { artist: { include: { user: true } } }, - }, - }, - }); + // Use the product of this line item to find the + // relevant item in our database. For merch this + // can be either product directly, or something defined + // by the merch options + if (stripeProductId) { + const product = await stripe.products.retrieve(stripeProductId); + + if (product.metadata.merchOptionIds) { + const optionIds = + product.metadata.merchOptionIds.split(OPTION_JOINER); + merchProduct = await prisma.merch.findFirst({ + where: { + optionTypes: { + some: { options: { some: { id: { in: optionIds } } } }, + }, + }, + include: { + optionTypes: { + include: { options: true }, + }, + }, + }); + } else { + merchProduct = await prisma.merch.findFirst({ + where: { + stripeProductKey: product.id, + }, + include: { + optionTypes: { + include: { options: true }, + }, + }, + }); + } + + if (merchProduct) { + const optionIds = + product.metadata?.merchOptionIds?.split(OPTION_JOINER); + const options: MerchOption[] = []; + merchProduct.optionTypes.forEach((ot) => + ot.options.forEach((o) => { + if (optionIds?.includes(o.id)) { + options.push(o); + } + }) + ); + + const createdMerchPurchase = await prisma.merchPurchase.create({ + data: { + userId, + merchId: merchProduct.id, + amountPaid: item.amount_total ?? 0, + currencyPaid: item.currency ?? "USD", + stripeTransactionKey: session?.id ?? null, + fulfillmentStatus: "NO_PROGRESS", + shippingAddress: session?.shipping_details?.address, + billingAddress: session?.customer_details?.address, + quantity: item.quantity ?? 1, + options: { + connect: options.map((o) => ({ + id: o.id, + })), + }, + }, + }); + const merch = await prisma.merch.findFirst({ + where: { id: createdMerchPurchase.merchId }, + }); + + if (merch?.quantityRemaining) { + await prisma.merch.update({ + where: { + id: createdMerchPurchase.merchId, + }, + data: { + quantityRemaining: + merch.quantityRemaining - createdMerchPurchase.quantity, + }, + }); + } + const merchPurchase = await prisma.merchPurchase.findFirst({ + where: { + id: createdMerchPurchase.id, + }, + include: { + merch: { + include: { artist: { include: { user: true } } }, + }, + }, + }); + const platformCut = await calculateAppFee( + createdMerchPurchase.amountPaid, + createdMerchPurchase.currencyPaid + ); + + return { + ...merchPurchase, + artistCut: (merchPurchase?.amountPaid ?? 0) - platformCut, + platformCut, + }; + } + } + }) ?? [] + ) + ).filter((o) => !!o); const user = await prisma.user.findFirst({ where: { @@ -204,9 +302,7 @@ export const handleArtistMerchPurchase = async ( }, }); - if (user && merchPurchase) { - const pricePaid = merchPurchase.amountPaid / 100; - + if (user && purchases.length > 0) { await sendMail({ data: { template: "artist-merch-purchase-receipt", @@ -214,37 +310,32 @@ export const handleArtistMerchPurchase = async ( to: user.email, }, locals: { - merchPurchase, + purchases, email: user.email, - pricePaid, + artist: purchases?.[0]?.merch?.artist, client: process.env.REACT_APP_CLIENT_DOMAIN, host: process.env.API_DOMAIN, }, }, } as Job); - const platformCut = await calculateAppFee( - pricePaid, - merchPurchase.currencyPaid - ); - await sendMail({ data: { template: "tell-artist-about-merch-purchase", message: { - to: merchPurchase.merch.artist.user.email, + to: purchases?.[0]?.merch?.artist.user.email, }, locals: { - merchPurchase, - pricePaid, - platformCut, + purchases, + artist: purchases?.[0]?.merch?.artist, + calculateAppFee, email: user.email, }, }, } as Job); } - return merchPurchase; + return purchases; } catch (e) { logger.error(`Error creating tip: ${e}`); throw e; diff --git a/src/utils/merch.ts b/src/utils/merch.ts index db024d436..7c3a4fcba 100644 --- a/src/utils/merch.ts +++ b/src/utils/merch.ts @@ -26,9 +26,11 @@ export const deleteMerchCover = async (merchId: string) => { } }; -export const processSingleMerch = (tg: Merch & { images: MerchImage[] }) => ({ - ...tg, - images: tg.images?.map((t) => addSizesToImage(finalMerchImageBucket, t)), +export const processSingleMerch = ( + merch: Merch & { images: MerchImage[] } +) => ({ + ...merch, + images: merch.images?.map((t) => addSizesToImage(finalMerchImageBucket, t)), }); export default { diff --git a/src/utils/ownership.ts b/src/utils/ownership.ts index fd5ae704a..bd6b063a1 100644 --- a/src/utils/ownership.ts +++ b/src/utils/ownership.ts @@ -93,6 +93,40 @@ export const doesMerchBelongToUser = async (merchId: string, user: User) => { return merch; }; +export const doesMerchPurchaseBelongToUser = async ( + purchaseId: string, + user: User +) => { + let merch; + if (user.isAdmin) { + merch = await prisma.merchPurchase.findFirst({ + where: { + id: purchaseId, + }, + }); + } else { + merch = await prisma.merchPurchase.findFirst({ + where: { + merch: { + artist: { + userId: user.id, + }, + }, + id: purchaseId, + }, + }); + } + + if (!merch) { + throw new AppError({ + description: "Merch purchase does not exist or does not belong to user", + httpCode: 404, + name: "Merch purchase does not exist or does not belong to user", + }); + } + return merch; +}; + export const doesTrackBelongToUser = async (trackId: number, user: User) => { try { const track = await prisma.track.findUnique({ diff --git a/src/utils/stripe.ts b/src/utils/stripe.ts index 135d9656e..5dd5a336d 100644 --- a/src/utils/stripe.ts +++ b/src/utils/stripe.ts @@ -1,6 +1,12 @@ import Stripe from "stripe"; import prisma from "@mirlo/prisma"; -import { Prisma, User } from "@mirlo/prisma/client"; +import { + MerchOptionType, + Prisma, + User, + MerchOption, + MerchShippingDestination, +} from "@mirlo/prisma/client"; import { logger } from "../logger"; import sendMail from "../jobs/send-mail"; import { Request, Response } from "express"; @@ -16,9 +22,12 @@ import { handleSubscription, handleTrackGroupPurchase, } from "./handleFinishedTransactions"; +import countryCodesCurrencies from "./country-codes-currencies"; const { STRIPE_KEY, API_DOMAIN } = process.env; +export const OPTION_JOINER = ";;"; + const stripe = new Stripe(STRIPE_KEY ?? "", { apiVersion: "2022-11-15", }); @@ -47,38 +56,104 @@ export const calculateAppFee = async ( return +appFee || 0; }; -const buildProductDescription = ( +const buildProductDescription = async ( title: string | null, artistName: string, - itemDescription?: string | null + itemDescription?: string | null, + options?: { merchOptionIds?: string[] } ) => { - const about = + let about = itemDescription && itemDescription !== "" ? itemDescription - : `The item ${title} by ${artistName}.`; + : `${title} by ${artistName}.`; + + if (options?.merchOptionIds) { + const foundOptions = await prisma.merchOption.findMany({ + where: { + id: { in: options.merchOptionIds }, + }, + include: { + merchOptionType: true, + }, + }); + + if (foundOptions.length > 0) { + about += `\n + ${foundOptions.map((o) => `${o.merchOptionType.optionName}: ${o.name}\n`)} + `; + } + } return about; }; +const checkForProductKey = async ( + stripeProductKey: string | null, + stripeAccountId: string, + options?: { merchOptionIds?: string[] } +) => { + if (options?.merchOptionIds && options?.merchOptionIds?.length > 0) { + const products = await stripe.products.search({ + query: `metadata["merchOptionIds"]:"${options.merchOptionIds.join(OPTION_JOINER)}"`, + }); + return products.data[0]?.id; + } + let productKey = stripeProductKey; + + if (productKey) { + try { + await stripe.products.retrieve(productKey, { + stripeAccount: stripeAccountId, + }); + } catch (e) { + if (e instanceof Error) { + if (e.message.includes("No such product")) { + logger.error("Weird, product doesn't exist", e.message); + productKey = null; + } + } + } + } else { + } + return productKey; +}; + +/** + * For Merch we don't store the stripeProductKey on the merch unless there are no options + * @param merch + * @param stripeAccountId + * @param options + * @returns + */ export const createMerchStripeProduct = async ( merch: Prisma.MerchGetPayload<{ include: { artist: true; images: true }; }>, - stripeAccountId: string + stripeAccountId: string, + options?: { merchOptionIds?: string[] } ) => { - let productKey = merch.stripeProductKey; - - const about = buildProductDescription( + let productKey = await checkForProductKey( + merch.stripeProductKey, + stripeAccountId, + options + ); + const about = await buildProductDescription( merch.title, merch.artist.name, - merch.description + merch.description, + options ); - if (!merch.stripeProductKey) { + if (!productKey) { const product = await stripe.products.create( { name: `${merch.title} by ${merch.artist.name}`, description: about, tax_code: "txcd_99999999", + metadata: { + merchOptionIds: options?.merchOptionIds + ? options?.merchOptionIds.join(OPTION_JOINER) + : null, + }, images: merch.images?.length > 0 ? [ @@ -93,14 +168,21 @@ export const createMerchStripeProduct = async ( stripeAccount: stripeAccountId, } ); - await prisma.merch.update({ - where: { - id: merch.id, - }, - data: { - stripeProductKey: product.id, - }, - }); + // do not set a product key if there are options + if ( + !options || + !options.merchOptionIds || + options.merchOptionIds.length === 0 + ) { + await prisma.merch.update({ + where: { + id: merch.id, + }, + data: { + stripeProductKey: product.id, + }, + }); + } productKey = product.id; } @@ -113,15 +195,18 @@ export const createTrackGroupStripeProduct = async ( }>, stripeAccountId: string ) => { - let productKey = trackGroup.stripeProductKey; + let productKey = await checkForProductKey( + trackGroup.stripeProductKey, + stripeAccountId + ); - const about = buildProductDescription( + const about = await buildProductDescription( trackGroup.title, trackGroup.artist.name, trackGroup.about ); - if (!trackGroup.stripeProductKey) { + if (!productKey) { const product = await stripe.products.create( { name: `${trackGroup.title} by ${trackGroup.artist.name}`, @@ -158,21 +243,10 @@ export const createSubscriptionStripeProduct = async ( tier: Prisma.ArtistSubscriptionTierGetPayload<{ include: { artist: true } }>, stripeAccountId: string ) => { - let productKey = tier.stripeProductKey; - if (productKey) { - try { - await stripe.products.retrieve(productKey, { - stripeAccount: stripeAccountId, - }); - } catch (e) { - if (e instanceof Error) { - if (e.message.includes("No such product")) { - console.error("Weird, product doesn't exist", e.message); - productKey = null; - } - } - } - } + let productKey = await checkForProductKey( + tier.stripeProductKey, + stripeAccountId + ); if (!productKey) { const product = await stripe.products.create( @@ -272,13 +346,73 @@ export const createStripeCheckoutSessionForPurchase = async ({ return session; }; +const stripeBannedDestinations = + "AS, CX, CC, CU, HM, IR, KP, MH, FM, NF, MP, PW, SD, SY, UM, VI".split(", "); + +const countryToCurrencyMap = countryCodesCurrencies.reduce((aggr, country) => ({ + ...aggr, + [country.countryCode]: country.currencyCode, +})); + +const determineShipping = ( + shippingDestinations: MerchShippingDestination[], + shippingDestinationId: string, + quantity: number = 0 +) => { + const destination = shippingDestinations.find( + (s) => s.id === shippingDestinationId + ); + + if (!destination) { + throw new AppError({ + httpCode: 400, + description: + "Supplied destination isn't a valid destination for the seller", + }); + } + + let possibleDestinations = [destination.destinationCountry]; + + if (destination?.destinationCountry === "") { + const specificShippingCosts = shippingDestinations.filter( + (d) => d.destinationCountry !== "" + ); + possibleDestinations = countryCodesCurrencies + .map((country) => { + const inSpecific = specificShippingCosts.find( + (d) => d.destinationCountry === country.countryCode + ); + const banned = stripeBannedDestinations.includes(country.countryCode); + if (banned || inSpecific) return null; + return country.countryCode; + }) + .filter((country) => !!country); + } + + return { + shipping_rate_data: { + display_name: `Shipping to ${!!destination.destinationCountry ? destination.destinationCountry : "Everywhere"}`, + fixed_amount: { + currency: destination?.currency, + amount: + destination?.costUnit + + (quantity > 1 ? quantity * destination?.costExtraUnit : 0), + }, + type: "fixed_amount" as "fixed_amount", + }, + destinationCodes: possibleDestinations, + }; +}; + export const createStripeCheckoutSessionForMerchPurchase = async ({ loggedInUser, email, priceNumber, merch, quantity, + options, stripeAccountId, + shippingDestinationId, }: { loggedInUser?: User; email?: string; @@ -287,6 +421,10 @@ export const createStripeCheckoutSessionForMerchPurchase = async ({ merch: Prisma.MerchGetPayload<{ include: { artist: true; images: true; shippingDestinations: true }; }>; + options: { + merchOptionIds: string[]; + }; + shippingDestinationId: string; stripeAccountId: string; }) => { const client = await prisma.client.findFirst({ @@ -295,7 +433,11 @@ export const createStripeCheckoutSessionForMerchPurchase = async ({ }, }); - const productKey = await createMerchStripeProduct(merch, stripeAccountId); + const productKey = await createMerchStripeProduct( + merch, + stripeAccountId, + options + ); if (!productKey) { throw new AppError({ @@ -306,18 +448,22 @@ export const createStripeCheckoutSessionForMerchPurchase = async ({ const currency = merch.currency?.toLowerCase() ?? "usd"; - const destinations = merch.shippingDestinations - .map((d) => d.destinationCountry?.toUpperCase()) - .filter( - (v) => !!v - ) as Stripe.Checkout.SessionCreateParams.ShippingAddressCollection.AllowedCountry[]; + const destinations = determineShipping( + merch.shippingDestinations, + shippingDestinationId, + quantity + ); const session = await stripe.checkout.sessions.create( { billing_address_collection: "required", shipping_address_collection: { - allowed_countries: destinations, // FIXME feed in shippingDesinations + allowed_countries: + destinations.destinationCodes as Stripe.Checkout.SessionCreateParams.ShippingAddressCollection.AllowedCountry[], }, + shipping_options: [ + { shipping_rate_data: destinations.shipping_rate_data }, + ], customer_email: loggedInUser?.email || email, payment_intent_data: { application_fee_amount: await calculateAppFee(priceNumber, currency), @@ -563,7 +709,7 @@ export const handleCheckoutSession = async ( await handleArtistGift(Number(actualUserId), Number(artistId), session); } else if (merchId && userEmail) { logger.info(`checkout.session: ${session.id} handling merch`); - await handleArtistMerchPurchase(Number(actualUserId), merchId, session); + await handleArtistMerchPurchase(Number(actualUserId), session); } else if (tierId && userEmail) { logger.info(`checkout.session: ${session.id} handling subscription`); await handleSubscription(Number(actualUserId), Number(tierId), session); diff --git a/test/utils/stripe.spec.ts b/test/utils/stripe.spec.ts new file mode 100644 index 000000000..e69de29bb