Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
Warnings:

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

*/
-- AlterEnum
ALTER TYPE "CustomerType" ADD VALUE 'CUSTOM';

-- AlterTable
ALTER TABLE "ItemQuantityChange" ADD COLUMN "customerType" "CustomerType" NOT NULL,
ALTER COLUMN "customerId" SET DATA TYPE TEXT;

-- AlterTable
ALTER TABLE "Subscription" ALTER COLUMN "customerId" SET DATA TYPE TEXT;
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;
34 changes: 21 additions & 13 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ model Project {
displayName String
description String @default("")
isProductionMode Boolean
ownerTeamId String? @db.Uuid
ownerTeamId String? @db.Uuid
logoUrl String?
fullLogoUrl String?

Expand Down Expand Up @@ -715,6 +715,7 @@ model ThreadMessage {
enum CustomerType {
USER
TEAM
CUSTOM
}

enum SubscriptionStatus {
Expand All @@ -728,35 +729,42 @@ enum SubscriptionStatus {
unpaid
}

enum SubscriptionCreationSource {
PURCHASE_PAGE
TEST_MODE
}

model Subscription {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId 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])
}

model ItemQuantityChange {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerId String @db.Uuid
itemId String
quantity Int
description String?
expiresAt DateTime?
createdAt DateTime @default(now())
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
customerType CustomerType
customerId String
itemId String
quantity Int
description String?
expiresAt DateTime?
createdAt DateTime @default(now())

@@id([tenancyId, id])
@@index([tenancyId, customerId, expiresAt])
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
@@ -1,8 +1,8 @@
import { ensureItemCustomerTypeMatches, getItemQuantityForCustomer } from "@/lib/payments";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";


Expand All @@ -17,6 +17,7 @@ export const GET = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
customer_id: yupString().defined(),
item_id: yupString().defined(),
}).defined(),
Expand All @@ -38,16 +39,23 @@ export const GET = createSmartRouteHandler({
if (!itemConfig) {
throw new KnownErrors.ItemNotFound(req.params.item_id);
}

await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy);
if (req.params.customer_type !== itemConfig.customerType) {
throw new KnownErrors.ItemCustomerTypeDoesNotMatch(req.params.item_id, req.params.customer_id, itemConfig.customerType, req.params.customer_type);
}
const prisma = await getPrismaClientForTenancy(tenancy);
await ensureCustomerExists({
prisma,
tenancyId: tenancy.id,
customerType: req.params.customer_type,
customerId: req.params.customer_id,
});
const totalQuantity = await getItemQuantityForCustomer({
prisma,
tenancy,
itemId: req.params.item_id,
customerId: req.params.customer_id,
customerType: req.params.customer_type,
});

return {
statusCode: 200,
bodyType: "json",
Expand All @@ -59,3 +67,5 @@ export const GET = createSmartRouteHandler({
};
},
});


Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ensureItemCustomerTypeMatches, getItemQuantityForCustomer } from "@/lib/payments";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { ensureCustomerExists, getItemQuantityForCustomer } from "@/lib/payments";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -16,6 +17,7 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
customer_id: yupString().defined(),
item_id: yupString().defined(),
}).defined(),
Expand Down Expand Up @@ -44,15 +46,24 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.ItemNotFound(req.params.item_id);
}

await ensureItemCustomerTypeMatches(req.params.item_id, itemConfig.customerType, req.params.customer_id, tenancy);
if (req.params.customer_type !== itemConfig.customerType) {
throw new KnownErrors.ItemCustomerTypeDoesNotMatch(req.params.item_id, req.params.customer_id, itemConfig.customerType, req.params.customer_type);
}
const prisma = await getPrismaClientForTenancy(tenancy);
await ensureCustomerExists({
prisma,
tenancyId: tenancy.id,
customerType: req.params.customer_type,
customerId: req.params.customer_id,
});

const changeId = await retryTransaction(prisma, async (tx) => {
const totalQuantity = await getItemQuantityForCustomer({
prisma: tx,
tenancy,
itemId: req.params.item_id,
customerId: req.params.customer_id,
customerType: req.params.customer_type,
});
if (!allowNegative && (totalQuantity + req.body.delta < 0)) {
throw new KnownErrors.ItemQuantityInsufficientAmount(req.params.item_id, req.params.customer_id, req.body.delta);
Expand All @@ -61,6 +72,7 @@ export const POST = createSmartRouteHandler({
data: {
tenancyId: tenancy.id,
customerId: req.params.customer_id,
customerType: typedToUppercase(req.params.customer_type),
itemId: req.params.item_id,
quantity: req.body.delta,
description: req.body.description,
Expand All @@ -77,3 +89,5 @@ export const POST = createSmartRouteHandler({
};
},
});


Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ensureOfferCustomerTypeMatches, ensureOfferIdOrInlineOffer } from "@/lib/payments";
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { ensureOfferIdOrInlineOffer } from "@/lib/payments";
import { getStripeForAccount } from "@/lib/stripe";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, clientOrHigherAuthTypeSchema, inlineOfferSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { purchaseUrlVerificationCodeHandler } from "../verification-code-handler";
import { CustomerType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -18,7 +19,8 @@ export const POST = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
body: yupObject({
customer_id: yupString().uuid().defined(),
customer_type: yupString().oneOf(["user", "team", "custom"]).defined(),
customer_id: yupString().defined(),
offer_id: yupString().optional(),
offer_inline: inlineOfferSchema.optional(),
}),
Expand All @@ -34,8 +36,10 @@ 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);
await ensureOfferCustomerTypeMatches(req.body.offer_id, offerConfig.customerType, req.body.customer_id, tenancy);
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);
}

const stripeCustomerSearch = await stripe.customers.search({
query: `metadata['customerId']:'${req.body.customer_id}'`,
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
Loading