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,11 @@
-- CreateTable
CREATE TABLE "ProductVersion" (
"tenancyId" UUID NOT NULL,
"productVersionId" TEXT NOT NULL,
"productId" TEXT,
"productJson" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ProductVersion_pkey" PRIMARY KEY ("tenancyId","productVersionId")
);
Comment thread
nams1570 marked this conversation as resolved.

10 changes: 10 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,16 @@ model Subscription {
@@unique([tenancyId, stripeSubscriptionId])
}

model ProductVersion {
tenancyId String @db.Uuid
productVersionId String
productId String?
productJson Json
createdAt DateTime @default(now())

@@id([tenancyId, productVersionId])
}
Comment thread
nams1570 marked this conversation as resolved.

model ItemQuantityChange {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
Expand Down
96 changes: 47 additions & 49 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction
import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config';
import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails';
import { AdminUserProjectsCrud, ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects';
import { ITEM_IDS, PLAN_LIMITS } from '@stackframe/stack-shared/dist/plans';
import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates';
import { throwErr } from '@stackframe/stack-shared/dist/utils/errors';
import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects';
Expand Down Expand Up @@ -119,26 +120,41 @@ export async function seed() {
},
},
products: {
team_plans: {
free: {
productLineId: "plans",
displayName: "Free",
customerType: "team",
serverOnly: false,
stackable: false,
prices: "include-by-default",
includedItems: {
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
},
},
team: {
productLineId: "plans",
displayName: "Team Plans",
displayName: "Team",
customerType: "team",
serverOnly: false,
stackable: false,
prices: {
monthly: {
USD: "49",
interval: [1, "month"] as any,
serverOnly: false
}
serverOnly: false,
},
},
includedItems: {
dashboard_admins: {
quantity: 3,
repeat: "never",
expires: "when-purchase-expires"
}
}
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
},
},
growth: {
productLineId: "plans",
Expand All @@ -150,63 +166,45 @@ export async function seed() {
monthly: {
USD: "299",
interval: [1, "month"] as any,
serverOnly: false
}
serverOnly: false,
},
},
includedItems: {
dashboard_admins: {
quantity: 5,
repeat: "never",
expires: "when-purchase-expires"
}
}
},
free: {
productLineId: "plans",
displayName: "Free",
customerType: "team",
serverOnly: false,
stackable: false,
prices: "include-by-default",
includedItems: {
dashboard_admins: {
quantity: 1,
repeat: "never",
expires: "when-purchase-expires"
}
}
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
},
},
"extra-admins": {
"extra-seats": {
productLineId: "plans",
displayName: "Extra Admins",
displayName: "Extra Seats",
customerType: "team",
serverOnly: false,
stackable: true,
prices: {
monthly: {
USD: "49",
USD: "29",
interval: [1, "month"] as any,
serverOnly: false
}
serverOnly: false,
},
},
includedItems: {
dashboard_admins: {
quantity: 1,
repeat: "never",
expires: "when-purchase-expires"
}
[ITEM_IDS.seats]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
},
isAddOnTo: {
team: true,
growth: true,
}
}
},
},
},
items: {
dashboard_admins: {
displayName: "Dashboard Admins",
customerType: "team"
}
[ITEM_IDS.seats]: { displayName: "Dashboard Admins", customerType: "team" as const },
[ITEM_IDS.authUsers]: { displayName: "Auth Users", customerType: "team" as const },
[ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const },
[ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const },
[ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const },
Comment thread
nams1570 marked this conversation as resolved.
},
},
apps: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails";
import { listPermissions } from "@/lib/permissions";
import { getStackStripe, getStripeForAccount, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
import { getStackStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
import type { StripeOverridesMap } from "@/lib/stripe-proxy";
import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram";
import { getTenancy, type Tenancy } from "@/lib/tenancies";
Expand Down Expand Up @@ -183,7 +183,14 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
}
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
const prisma = await getPrismaClientForTenancy(tenancy);
const product = JSON.parse(metadata.product || "{}");

const product = await resolveProductFromStripeMetadata({
prisma,
tenancyId: tenancy.id,
metadata: metadata as Record<string, string | undefined>,
context: { paymentIntentId: paymentIntent.id },
});

const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
const stripePaymentIntentId = paymentIntent.id;
if (!metadata.customerId || !metadata.customerType) {
Expand Down Expand Up @@ -226,7 +233,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
customerId: metadata.customerId,
});
const receiptLink = paymentIntent.charges?.data?.[0]?.receipt_url ?? null;
const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase";
const productName = product.displayName ?? "Purchase";
const extraVariables: Record<string, string | number> = {
productName,
quantity: qty,
Expand Down Expand Up @@ -264,8 +271,13 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
customerType,
customerId: metadata.customerId,
});
const product = JSON.parse(metadata.product || "{}");
const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase";
const product = await resolveProductFromStripeMetadata({
prisma,
tenancyId: tenancy.id,
metadata: metadata as Record<string, string | undefined>,
context: { paymentIntentId: paymentIntent.id },
});
const productName = product.displayName ?? "Purchase";
const failureReason = paymentIntent.last_payment_error?.message;
const extraVariables: Record<string, string | number> = {
productName,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { SubscriptionStatus } from "@/generated/prisma/client";
import { ensureClientCanAccessCustomer, getCustomerPurchaseContext, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments";
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getStripeForAccount } from "@/lib/stripe";
import { upsertProductVersion } from "@/lib/product-versions";
import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined, typedEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
import { SubscriptionStatus } from "@/generated/prisma/client";
import Stripe from "stripe";


Expand Down Expand Up @@ -170,6 +170,13 @@ export const POST = createSmartRouteHandler({

const stripeProduct = await stripe.products.create({ name: toProduct.displayName || "Subscription" });

const productVersionId = await upsertProductVersion({
prisma,
tenancyId: auth.tenancy.id,
productId: body.to_product_id,
productJson: toProduct,
});

if (subscription?.stripeSubscriptionId) {
const existingStripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
if (existingStripeSub.items.data.length === 0) {
Expand All @@ -195,11 +202,16 @@ export const POST = createSmartRouteHandler({
}],
metadata: {
productId: body.to_product_id,
product: JSON.stringify(toProduct),
productVersionId,
priceId: selectedPriceId,
},
});
const updatedSubscription = updated as Stripe.Subscription;
const sanitizedUpdateDates = sanitizeStripePeriodDates(
existingItem.current_period_start,
existingItem.current_period_end,
{ subscriptionId: subscription.stripeSubscriptionId, tenancyId: auth.tenancy.id }
);

await prisma.subscription.update({
where: {
Expand All @@ -214,8 +226,8 @@ export const POST = createSmartRouteHandler({
priceId: selectedPriceId,
quantity,
status: updatedSubscription.status,
currentPeriodStart: new Date(existingItem.current_period_start * 1000),
currentPeriodEnd: new Date(existingItem.current_period_end * 1000),
currentPeriodStart: sanitizedUpdateDates.start,
currentPeriodEnd: sanitizedUpdateDates.end,
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end,
},
});
Expand All @@ -239,7 +251,7 @@ export const POST = createSmartRouteHandler({
}],
metadata: {
productId: body.to_product_id,
product: JSON.stringify(toProduct),
productVersionId,
priceId: selectedPriceId,
},
});
Expand All @@ -248,6 +260,11 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("Stripe subscription has no items", { stripeSubscriptionId: createdSubscription.id });
}
const createdItem = createdSubscription.items.data[0];
const sanitizedCreateDates = sanitizeStripePeriodDates(
createdItem.current_period_start,
createdItem.current_period_end,
{ subscriptionId: createdSubscription.id, tenancyId: auth.tenancy.id }
);

await prisma.subscription.create({
data: {
Expand All @@ -260,8 +277,8 @@ export const POST = createSmartRouteHandler({
quantity,
stripeSubscriptionId: createdSubscription.id,
status: createdSubscription.status,
currentPeriodStart: new Date(createdItem.current_period_start * 1000),
currentPeriodEnd: new Date(createdItem.current_period_end * 1000),
currentPeriodStart: sanitizedCreateDates.start,
currentPeriodEnd: sanitizedCreateDates.end,
cancelAtPeriodEnd: createdSubscription.cancel_at_period_end,
creationSource: "PURCHASE_PAGE",
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { SubscriptionStatus } from "@/generated/prisma/client";
import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments";
import { upsertProductVersion } from "@/lib/product-versions";
import { getStripeForAccount } from "@/lib/stripe";
import { getTenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { SubscriptionStatus } from "@/generated/prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
Expand Down Expand Up @@ -73,6 +74,13 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("Price not resolved for purchase session");
}

const productVersionId = await upsertProductVersion({
prisma,
tenancyId: tenancy.id,
productId: data.productId ?? null,
productJson: data.product,
});

if (conflictingProductLineSubscriptions.length > 0) {
const conflicting = conflictingProductLineSubscriptions[0];
if (conflicting.stripeSubscriptionId) {
Expand All @@ -99,7 +107,7 @@ export const POST = createSmartRouteHandler({
}],
metadata: {
productId: data.productId ?? null,
product: JSON.stringify(data.product),
productVersionId,
priceId: price_id,
},
});
Expand Down Expand Up @@ -136,7 +144,7 @@ export const POST = createSmartRouteHandler({
automatic_payment_methods: { enabled: true },
metadata: {
productId: data.productId || "",
product: JSON.stringify(data.product),
productVersionId,
customerId: data.customerId,
customerType: data.product.customerType,
purchaseQuantity: String(quantity),
Expand Down Expand Up @@ -175,7 +183,7 @@ export const POST = createSmartRouteHandler({
}],
metadata: {
productId: data.productId ?? null,
product: JSON.stringify(data.product),
productVersionId,
priceId: price_id,
},
});
Expand Down
Loading