Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
Warnings:

- Added the required column `creationSource` to the `Subscription` table without a default value. This is not possible if the table is not empty.

*/
-- CreateEnum
CREATE TYPE "SubscriptionCreationSource" AS ENUM ('PURCHASE_PAGE', 'TEST_MODE');

-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "creationSource" "SubscriptionCreationSource" NOT NULL,
ALTER COLUMN "stripeSubscriptionId" DROP NOT NULL;
12 changes: 9 additions & 3 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -729,21 +729,27 @@ enum SubscriptionStatus {
unpaid
}

enum SubscriptionCreationSource {
PURCHASE_PAGE
TEST_MODE
}

model Subscription {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId String
customerType CustomerType
offer Json

stripeSubscriptionId String
stripeSubscriptionId String?
status SubscriptionStatus
currentPeriodEnd DateTime
currentPeriodStart DateTime
cancelAtPeriodEnd Boolean

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creationSource SubscriptionCreationSource
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@id([tenancyId, id])
@@unique([tenancyId, stripeSubscriptionId])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { addInterval } from "@stackframe/stack-shared/dist/utils/dates";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: adminAuthTypeSchema.defined(),
project: adaptSchema.defined(),
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
full_code: yupString().defined(),
price_id: yupString().defined(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["success"]).defined(),
}),
handler: async ({ auth, body }) => {
const { full_code, price_id } = body;
const { data, id: codeId } = await purchaseUrlVerificationCodeHandler.validateCode(full_code);
if (auth.tenancy.id !== data.tenancyId) {
throw new StatusError(400, "Tenancy id does not match value from code data");
}
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const pricesMap = new Map(Object.entries(data.offer.prices));
const selectedPrice = pricesMap.get(price_id);
if (!selectedPrice) {
throw new StatusError(400, "Price not found on offer associated with this purchase code");
}
if (!selectedPrice.interval) {
throw new StackAssertionError("unimplemented; prices without an interval are currently not supported");
}
await prisma.subscription.create({
data: {
tenancyId: auth.tenancy.id,
customerId: data.customerId,
customerType: typedToUppercase(data.offer.customerType),
status: "active",
offer: data.offer,
currentPeriodStart: new Date(),
currentPeriodEnd: addInterval(new Date(), selectedPrice.interval),
cancelAtPeriodEnd: false,
creationSource: "TEST_MODE",
},
});
await purchaseUrlVerificationCodeHandler.revokeCode({
tenancy: auth.tenancy,
id: codeId,
});

return {
statusCode: 200,
bodyType: "success",
};
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const POST = createSmartRouteHandler({
const { tenancy } = req.auth;
const stripe = getStripeForAccount({ tenancy });
const offerConfig = await ensureOfferIdOrInlineOffer(tenancy, req.auth.type, req.body.offer_id, req.body.offer_inline);
const customerType = offerConfig.customerType ?? throwErr("Customer type not found");
const customerType = offerConfig.customerType;
if (req.body.customer_type !== customerType) {
throw new KnownErrors.OfferCustomerTypeDoesNotMatch(req.body.offer_id, req.body.customer_id, customerType, req.body.customer_type);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getTenancy } from "@/lib/tenancies";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -24,7 +25,11 @@ export const POST = createSmartRouteHandler({
}),
async handler({ body }) {
const { full_code, price_id } = body;
const { data } = await purchaseUrlVerificationCodeHandler.validateCode(full_code);
const { data, id: codeId } = await purchaseUrlVerificationCodeHandler.validateCode(full_code);
const tenancy = await getTenancy(data.tenancyId);
if (!tenancy) {
throw new StackAssertionError("No tenancy found from purchase code data tenancy id. This should never happen.");
}
const stripe = getStripeForAccount({ accountId: data.stripeAccountId });
const pricesMap = new Map(Object.entries(data.offer.prices));
const selectedPrice = pricesMap.get(price_id);
Expand All @@ -35,6 +40,7 @@ export const POST = createSmartRouteHandler({
if (!selectedPrice.interval) {
throw new StackAssertionError("unimplemented; prices without an interval are currently not supported");
}

const product = await stripe.products.create({
name: data.offer.displayName ?? "Subscription",
});
Expand All @@ -59,6 +65,11 @@ export const POST = createSmartRouteHandler({
offer: JSON.stringify(data.offer),
},
});
await purchaseUrlVerificationCodeHandler.revokeCode({
tenancy,
id: codeId,
});

const clientSecret = (subscription.latest_invoice as Stripe.Invoice).confirmation_secret?.client_secret;
// stripe-mock returns an empty string here
if (typeof clientSecret !== "string") {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
import { inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { filterUndefined, typedFromEntries, getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currencies";
import * as yup from "yup";
import { getTenancy } from "@/lib/tenancies";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";

const offerDataSchema = inlineOfferSchema.omit(["server_only", "included_items"]);

Expand All @@ -22,14 +24,19 @@ export const POST = createSmartRouteHandler({
body: yupObject({
offer: offerDataSchema,
stripe_account_id: yupString().defined(),
project_id: yupString().defined(),
}).defined(),
}),
async handler({ body }) {
const verificationCode = await purchaseUrlVerificationCodeHandler.validateCode(body.full_code);
const tenancy = await getTenancy(verificationCode.data.tenancyId);
if (!tenancy) {
throw new StackAssertionError(`No tenancy found for given tenancyId`);
}
const offer = verificationCode.data.offer;
const offerData: yup.InferType<typeof offerDataSchema> = {
display_name: offer.displayName ?? "Offer",
customer_type: offer.customerType ?? "user",
customer_type: offer.customerType,
prices: Object.fromEntries(Object.entries(offer.prices).map(([key, value]) => [key, filterUndefined({
...typedFromEntries(SUPPORTED_CURRENCIES.map(c => [c.code, getOrUndefined(value, c.code)])),
interval: value.interval,
Expand All @@ -43,6 +50,7 @@ export const POST = createSmartRouteHandler({
body: {
offer: offerData,
stripe_account_id: verificationCode.data.stripeAccountId,
project_id: tenancy.project.id,
},
};
},
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function ensureOfferIdOrInlineOffer(
}])),
includedItems: typedFromEntries(Object.entries(inlineOffer.included_items).map(([key, value]) => [key, {
repeat: value.repeat ?? "never",
quantity: value.quantity,
quantity: value.quantity ?? 0,
expires: value.expires ?? "never",
}])),
};
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/lib/stripe.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import Stripe from "stripe";
import { getTenancy, Tenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { CustomerType } from "@prisma/client";
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import Stripe from "stripe";
import { overrideEnvironmentConfigOverride } from "./config";

const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY");
Expand Down Expand Up @@ -84,6 +84,7 @@ export async function syncStripeSubscriptions(stripeAccountId: string, stripeCus
currentPeriodEnd: new Date(subscription.items.data[0].current_period_end * 1000),
currentPeriodStart: new Date(subscription.items.data[0].current_period_start * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
creationSource: "PURCHASE_PAGE"
},
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { PaymentItemTable } from "@/components/data-table/payment-item-table";
import { ItemDialog } from "@/components/payments/item-dialog";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
import { DialogOpener } from "@/components/dialog-opener";


export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const config = project.useConfig();
const paymentsConfig = config.payments;

return (
<PageLayout
title="Items"
description="Manage your payment items"
actions={
<DialogOpener triggerLabel="New Item">
{state => (
<ItemDialog
open={state.isOpen}
onOpenChange={state.setIsOpen}
project={project}
mode="create"
/>
)}
</DialogOpener>
}
>
<PaymentItemTable items={paymentsConfig.items} />
</PageLayout>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { devFeaturesEnabledForProject } from "@/lib/utils";
import { notFound } from "next/navigation";
import PageClient from "./page-client";

export const metadata = {
title: "Items",
};

type Params = {
projectId: string,
};

export default async function Page({ params }: { params: Promise<Params> }) {
const { projectId } = await params;
if (!devFeaturesEnabledForProject(projectId)) {
notFound();
}
return (
<PageClient />
);
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { PaymentOfferTable } from "@/components/data-table/payment-offer-table";
import { OfferDialog } from "@/components/payments/offer-dialog";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
import { DialogOpener } from "@/components/dialog-opener";

export default function PageClient() {
const stackAdminApp = useAdminApp();
const project = stackAdminApp.useProject();
const config = project.useConfig();
const paymentsConfig = config.payments;

return (
<PageLayout
title="Offers"
description="Manage your payment offers"
actions={<DialogOpener triggerLabel="New Offer">
{state => (
<OfferDialog
open={state.isOpen}
onOpenChange={state.setIsOpen}
project={project}
mode="create"
/>
)}
</DialogOpener>}
>
<PaymentOfferTable offers={paymentsConfig.offers} />
</PageLayout>
);
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { devFeaturesEnabledForProject } from "@/lib/utils";
import { notFound } from "next/navigation";
import PageClient from "./page-client";

export const metadata = {
title: "Offers",
};

type Params = {
projectId: string,
};

export default async function Page({ params }: { params: Promise<Params> }) {
const { projectId } = await params;
if (!devFeaturesEnabledForProject(projectId)) {
notFound();
}
return (
<PageClient />
);
}


Loading