mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
[Fix] [Refactor] Implement Base Settings for Stack-Auth Plans and Move Metadata from Stripe Webhook Event to Table (#1214)
### Context We're looking at implementing plan pricing. While doing so, we encountered a problem with Stripe. **Problem:** when we run a stripe operation (purchase), the product info is encoded as part of the stripe metadata request. Stripe encodes metadata as key-value pairs, and the [value has a limit of 500 chars](https://docs.stripe.com/metadata#data). We do this because once we run the stripe operation, stripe fires a webhook event which is caught by our stripe webhook handler syncStripeSubscriptions. This gets the stripe metadata info from the event and then updates our db in prisma. ### Summary of Changes We add a `ProductVersion` table and only pass the `productVersionId` via stripe metadata instead of the whole product json. This `productVersionId` is created by hashing the `productJson`. Since the same product may be ordered differently without being intrinsically different, we add a helper function for ensuring a canonical order to the json. We also pass tenancy id and product id to the table. Since there are existing subscriptions which used to pass the productJson via metadata, we ensure backwards compatibility.
This commit is contained in:
parent
975f0e7ca5
commit
e9886bc45a
@ -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")
|
||||
);
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
model ItemQuantityChange {
|
||||
id String @default(uuid()) @db.Uuid
|
||||
tenancyId String @db.Uuid
|
||||
|
||||
@ -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';
|
||||
@ -119,9 +120,24 @@ export async function seed() {
|
||||
},
|
||||
},
|
||||
products: {
|
||||
team_plans: {
|
||||
free: {
|
||||
productLineId: "plans",
|
||||
displayName: "Team 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",
|
||||
customerType: "team",
|
||||
serverOnly: false,
|
||||
stackable: false,
|
||||
@ -129,16 +145,16 @@ export async function seed() {
|
||||
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",
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
[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 },
|
||||
},
|
||||
},
|
||||
free: {
|
||||
"extra-seats": {
|
||||
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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extra-admins": {
|
||||
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 },
|
||||
},
|
||||
},
|
||||
apps: {
|
||||
|
||||
@ -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";
|
||||
@ -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) {
|
||||
@ -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,
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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";
|
||||
@ -8,7 +9,6 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupOb
|
||||
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";
|
||||
|
||||
|
||||
@ -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) {
|
||||
@ -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: {
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -239,7 +251,7 @@ export const POST = createSmartRouteHandler({
|
||||
}],
|
||||
metadata: {
|
||||
productId: body.to_product_id,
|
||||
product: JSON.stringify(toProduct),
|
||||
productVersionId,
|
||||
priceId: selectedPriceId,
|
||||
},
|
||||
});
|
||||
@ -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: {
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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";
|
||||
@ -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) {
|
||||
@ -99,7 +107,7 @@ export const POST = createSmartRouteHandler({
|
||||
}],
|
||||
metadata: {
|
||||
productId: data.productId ?? null,
|
||||
product: JSON.stringify(data.product),
|
||||
productVersionId,
|
||||
priceId: price_id,
|
||||
},
|
||||
});
|
||||
@ -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),
|
||||
@ -175,7 +183,7 @@ export const POST = createSmartRouteHandler({
|
||||
}],
|
||||
metadata: {
|
||||
productId: data.productId ?? null,
|
||||
product: JSON.stringify(data.product),
|
||||
productVersionId,
|
||||
priceId: price_id,
|
||||
},
|
||||
});
|
||||
|
||||
177
apps/backend/src/lib/product-versions.tsx
Normal file
177
apps/backend/src/lib/product-versions.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import { PrismaClientTransaction } from "@/prisma-client";
|
||||
import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
|
||||
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Deterministically serializes an object to JSON with sorted keys.
|
||||
* This ensures the same object always produces the same string regardless of property order.
|
||||
*/
|
||||
export function canonicalJsonStringify(obj: unknown): string {
|
||||
return JSON.stringify(obj, (_, value) => {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.keys(value)
|
||||
.sort()
|
||||
.reduce((sorted: Record<string, unknown>, key) => {
|
||||
sorted[key] = value[key];
|
||||
return sorted;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a deterministic version ID from a productId and product JSON object.
|
||||
* Uses SHA-256 hash of the canonical JSON representation.
|
||||
*
|
||||
* Including productId ensures different products with identical JSON get separate rows.
|
||||
* Inline products (null productId) with identical JSON will still share a row,
|
||||
* which is acceptable since if they have the same productJson, semantically they are the same product.
|
||||
*/
|
||||
export function computeProductVersionId(productId: string | null, productJson: unknown): string {
|
||||
const canonical = canonicalJsonStringify({ productId, productJson });
|
||||
const hash = crypto.createHash("sha256").update(canonical).digest();
|
||||
return encodeBase64(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts a ProductVersion record and returns the productVersionId.
|
||||
* If a record with the same (tenancyId, productVersionId) exists, it's a no-op.
|
||||
*/
|
||||
export async function upsertProductVersion(options: {
|
||||
prisma: PrismaClientTransaction,
|
||||
tenancyId: string,
|
||||
productId: string | null,
|
||||
productJson: unknown,
|
||||
}): Promise<string> {
|
||||
const productVersionId = computeProductVersionId(options.productId, options.productJson);
|
||||
|
||||
await options.prisma.productVersion.upsert({
|
||||
where: {
|
||||
tenancyId_productVersionId: {
|
||||
tenancyId: options.tenancyId,
|
||||
productVersionId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenancyId: options.tenancyId,
|
||||
productVersionId,
|
||||
productId: options.productId,
|
||||
productJson: options.productJson as object,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
return productVersionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a ProductVersion by tenancyId and productVersionId.
|
||||
* Throws if not found.
|
||||
*/
|
||||
export async function getProductVersion(options: {
|
||||
prisma: PrismaClientTransaction,
|
||||
tenancyId: string,
|
||||
productVersionId: string,
|
||||
}): Promise<{ productId: string | null, productJson: unknown }> {
|
||||
const version = await options.prisma.productVersion.findUnique({
|
||||
where: {
|
||||
tenancyId_productVersionId: {
|
||||
tenancyId: options.tenancyId,
|
||||
productVersionId: options.productVersionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!version) {
|
||||
throw new StackAssertionError(
|
||||
"ProductVersion not found. This may indicate a race condition or deleted record.",
|
||||
{
|
||||
tenancyId: options.tenancyId,
|
||||
productVersionId: options.productVersionId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
productId: version.productId,
|
||||
productJson: version.productJson,
|
||||
};
|
||||
}
|
||||
|
||||
import.meta.vitest?.describe("canonicalJsonStringify", (test) => {
|
||||
test("produces same output regardless of key order", ({ expect }) => {
|
||||
const obj1 = { b: 2, a: 1, c: 3 };
|
||||
const obj2 = { a: 1, b: 2, c: 3 };
|
||||
const obj3 = { c: 3, b: 2, a: 1 };
|
||||
|
||||
expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2));
|
||||
expect(canonicalJsonStringify(obj2)).toBe(canonicalJsonStringify(obj3));
|
||||
});
|
||||
|
||||
test("handles nested objects", ({ expect }) => {
|
||||
const obj1 = { outer: { b: 2, a: 1 }, z: 1 };
|
||||
const obj2 = { z: 1, outer: { a: 1, b: 2 } };
|
||||
|
||||
expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2));
|
||||
});
|
||||
|
||||
test("preserves array order", ({ expect }) => {
|
||||
const obj1 = { arr: [1, 2, 3] };
|
||||
const obj2 = { arr: [3, 2, 1] };
|
||||
|
||||
expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2));
|
||||
});
|
||||
|
||||
test("different objects produce different output", ({ expect }) => {
|
||||
const obj1 = { a: 1 };
|
||||
const obj2 = { a: 2 };
|
||||
|
||||
expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2));
|
||||
});
|
||||
});
|
||||
|
||||
import.meta.vitest?.describe("computeProductVersionId", (test) => {
|
||||
test("produces same hash for same productId and object with different key order", ({ expect }) => {
|
||||
const obj1 = { b: 2, a: 1, c: 3 };
|
||||
const obj2 = { a: 1, b: 2, c: 3 };
|
||||
|
||||
expect(computeProductVersionId("prod-1", obj1)).toBe(computeProductVersionId("prod-1", obj2));
|
||||
});
|
||||
|
||||
test("produces different hash for different objects", ({ expect }) => {
|
||||
const obj1 = { a: 1 };
|
||||
const obj2 = { a: 2 };
|
||||
|
||||
expect(computeProductVersionId("prod-1", obj1)).not.toBe(computeProductVersionId("prod-1", obj2));
|
||||
});
|
||||
|
||||
test("produces different hash for different productIds with same object", ({ expect }) => {
|
||||
const obj = { a: 1 };
|
||||
|
||||
expect(computeProductVersionId("prod-1", obj)).not.toBe(computeProductVersionId("prod-2", obj));
|
||||
});
|
||||
|
||||
test("produces same hash for null productIds with same object", ({ expect }) => {
|
||||
const obj = { a: 1 };
|
||||
|
||||
expect(computeProductVersionId(null, obj)).toBe(computeProductVersionId(null, obj));
|
||||
});
|
||||
|
||||
test("produces different hash for null productIds with different objects", ({ expect }) => {
|
||||
const obj1 = { a: 1 };
|
||||
const obj2 = { a: 2 };
|
||||
|
||||
expect(computeProductVersionId(null, obj1)).not.toBe(computeProductVersionId(null, obj2));
|
||||
});
|
||||
|
||||
test("hash is deterministic", ({ expect }) => {
|
||||
const obj = { foo: "bar", nested: { x: 1, y: 2 } };
|
||||
|
||||
const hash1 = computeProductVersionId("prod-1", obj);
|
||||
const hash2 = computeProductVersionId("prod-1", obj);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,14 @@
|
||||
import { CustomerType } from "@/generated/prisma/client";
|
||||
import { getProductVersion } from "@/lib/product-versions";
|
||||
import { getTenancy, Tenancy } from "@/lib/tenancies";
|
||||
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
|
||||
import { CustomerType } from "@/generated/prisma/client";
|
||||
import type { productSchema } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays";
|
||||
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import Stripe from "stripe";
|
||||
import type * as yup from "yup";
|
||||
import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy";
|
||||
import { InputJsonValue } from "@prisma/client/runtime/client";
|
||||
|
||||
const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", "");
|
||||
const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment());
|
||||
@ -17,6 +19,170 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? {
|
||||
port: Number(`${stackPortPrefix}23`),
|
||||
} : {};
|
||||
|
||||
/** Product type as stored in Stripe metadata (same as config product schema) */
|
||||
export type StripeMetadataProduct = yup.InferType<typeof productSchema>;
|
||||
|
||||
/**
|
||||
* Sanitizes subscription period dates from Stripe.
|
||||
*
|
||||
* The Stripe mock returns hardcoded fixture dates that are invalid (e.g., start in 2030, end in 2000).
|
||||
* This function detects when end <= start and replaces with sensible defaults.
|
||||
*
|
||||
* We only check the ordering constraint to avoid interfering with legitimate Stripe dates
|
||||
* (e.g., long trials, future billing anchors).
|
||||
*
|
||||
* @param startTimestamp - Unix timestamp in seconds for period start
|
||||
* @param endTimestamp - Unix timestamp in seconds for period end
|
||||
* @param context - Optional context for error reporting (subscriptionId, tenancyId)
|
||||
* @returns Sanitized Date objects for start and end
|
||||
*/
|
||||
export function sanitizeStripePeriodDates(
|
||||
startTimestamp: number,
|
||||
endTimestamp: number,
|
||||
context?: { subscriptionId?: string, tenancyId?: string },
|
||||
): { start: Date, end: Date } {
|
||||
const startDate = new Date(startTimestamp * 1000);
|
||||
const endDate = new Date(endTimestamp * 1000);
|
||||
|
||||
if (startDate < endDate) {
|
||||
return { start: startDate, end: endDate };
|
||||
}
|
||||
|
||||
// Dates are invalid (likely from Stripe mock where end <= start), use sensible defaults
|
||||
captureError("sanitize-stripe-period-dates", new StackAssertionError(
|
||||
"Invalid Stripe period dates detected (end <= start), using fallback dates",
|
||||
{ startTimestamp, endTimestamp, startDate, endDate, useStripeMock, ...context }
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const defaultEnd = new Date(now);
|
||||
defaultEnd.setMonth(defaultEnd.getMonth() + 1);
|
||||
|
||||
return { start: now, end: defaultEnd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves product JSON from Stripe metadata with backward compatibility.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. productVersionId - new approach, looks up ProductVersion table
|
||||
* 2. product - older approach, JSON string in metadata
|
||||
* 3. offer - oldest approach, JSON string in metadata (legacy naming)
|
||||
*
|
||||
* @throws StackAssertionError if none of the above are found
|
||||
*/
|
||||
export async function resolveProductFromStripeMetadata(options: {
|
||||
prisma: Parameters<typeof getProductVersion>[0]['prisma'],
|
||||
tenancyId: string,
|
||||
metadata: Record<string, string | undefined>,
|
||||
context?: { subscriptionId?: string, paymentIntentId?: string },
|
||||
}): Promise<StripeMetadataProduct> {
|
||||
const productVersionId = options.metadata.productVersionId;
|
||||
if (productVersionId) {
|
||||
const version = await getProductVersion({
|
||||
prisma: options.prisma,
|
||||
tenancyId: options.tenancyId,
|
||||
productVersionId,
|
||||
});
|
||||
return version.productJson as StripeMetadataProduct;
|
||||
}
|
||||
|
||||
const productString = options.metadata.product ?? options.metadata.offer;
|
||||
if (productString) {
|
||||
try {
|
||||
return JSON.parse(productString) as StripeMetadataProduct;
|
||||
} catch (error) {
|
||||
throw new StackAssertionError(
|
||||
"Failed to parse product JSON from Stripe metadata. The 'product' or 'offer' field contains invalid JSON.",
|
||||
{
|
||||
...options.context,
|
||||
tenancyId: options.tenancyId,
|
||||
productString,
|
||||
metadata: options.metadata,
|
||||
error,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new StackAssertionError(
|
||||
"Stripe metadata is missing product information. Expected one of: 'productVersionId' (current), 'product' (legacy), or 'offer' (oldest). This may indicate the purchase was created before product tracking was implemented, or the metadata was corrupted.",
|
||||
{
|
||||
...options.context,
|
||||
tenancyId: options.tenancyId,
|
||||
metadata: options.metadata,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
import.meta.vitest?.describe("resolveProductFromStripeMetadata", (test) => {
|
||||
const mockProduct = { displayName: "Test Product", customerType: "team" as const };
|
||||
|
||||
// Note: productVersionId path is tested via E2E tests since it requires database mocking
|
||||
|
||||
test("falls back to 'product' metadata (legacy format)", async ({ expect }) => {
|
||||
const result = await resolveProductFromStripeMetadata({
|
||||
prisma: {} as any,
|
||||
tenancyId: "tenant-1",
|
||||
metadata: { product: JSON.stringify(mockProduct) },
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockProduct);
|
||||
});
|
||||
|
||||
test("falls back to 'offer' metadata (oldest format)", async ({ expect }) => {
|
||||
const result = await resolveProductFromStripeMetadata({
|
||||
prisma: {} as any,
|
||||
tenancyId: "tenant-1",
|
||||
metadata: { offer: JSON.stringify(mockProduct) },
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockProduct);
|
||||
});
|
||||
|
||||
test("prefers 'product' over 'offer' when both present", async ({ expect }) => {
|
||||
const offerProduct = { displayName: "Offer Product", customerType: "user" as const };
|
||||
|
||||
const result = await resolveProductFromStripeMetadata({
|
||||
prisma: {} as any,
|
||||
tenancyId: "tenant-1",
|
||||
metadata: {
|
||||
product: JSON.stringify(mockProduct),
|
||||
offer: JSON.stringify(offerProduct),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockProduct);
|
||||
});
|
||||
|
||||
test("throws on invalid JSON in product field", async ({ expect }) => {
|
||||
await expect(resolveProductFromStripeMetadata({
|
||||
prisma: {} as any,
|
||||
tenancyId: "tenant-1",
|
||||
metadata: { product: "not valid json" },
|
||||
})).rejects.toThrow("Failed to parse product JSON");
|
||||
});
|
||||
|
||||
test("throws when no product info in metadata", async ({ expect }) => {
|
||||
await expect(resolveProductFromStripeMetadata({
|
||||
prisma: {} as any,
|
||||
tenancyId: "tenant-1",
|
||||
metadata: {},
|
||||
})).rejects.toThrow("Stripe metadata is missing product information");
|
||||
});
|
||||
|
||||
test("includes context in error when provided", async ({ expect }) => {
|
||||
await expect(resolveProductFromStripeMetadata({
|
||||
prisma: {} as any,
|
||||
tenancyId: "tenant-1",
|
||||
metadata: {},
|
||||
context: { subscriptionId: "sub-123" },
|
||||
})).rejects.toMatchObject({
|
||||
message: expect.stringContaining("missing product information"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const getStackStripe = (overrides?: StripeOverridesMap) => {
|
||||
if (!stripeSecretKey) {
|
||||
throw new StackAssertionError("STACK_STRIPE_SECRET_KEY environment variable is not set");
|
||||
@ -92,18 +258,19 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
|
||||
continue;
|
||||
}
|
||||
const item = subscription.items.data[0];
|
||||
const sanitizedDates = sanitizeStripePeriodDates(
|
||||
item.current_period_start,
|
||||
item.current_period_end,
|
||||
{ subscriptionId: subscription.id, tenancyId: tenancy.id }
|
||||
);
|
||||
const priceId = subscription.metadata.priceId as string | undefined;
|
||||
// old subscriptions were created with offer metadata instead of product metadata
|
||||
const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined;
|
||||
if (!productString) {
|
||||
throw new StackAssertionError("Stripe subscription metadata missing product or offer", { subscriptionId: subscription.id });
|
||||
}
|
||||
let productJson: InputJsonValue;
|
||||
try {
|
||||
productJson = JSON.parse(productString);
|
||||
} catch (error) {
|
||||
throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error });
|
||||
}
|
||||
|
||||
const product = await resolveProductFromStripeMetadata({
|
||||
prisma,
|
||||
tenancyId: tenancy.id,
|
||||
metadata: subscription.metadata as Record<string, string | undefined>,
|
||||
context: { subscriptionId: subscription.id },
|
||||
});
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
@ -114,10 +281,10 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
|
||||
},
|
||||
update: {
|
||||
status: subscription.status,
|
||||
product: productJson,
|
||||
product,
|
||||
quantity: item.quantity ?? 1,
|
||||
currentPeriodEnd: new Date(item.current_period_end * 1000),
|
||||
currentPeriodStart: new Date(item.current_period_start * 1000),
|
||||
currentPeriodEnd: sanitizedDates.end,
|
||||
currentPeriodStart: sanitizedDates.start,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
priceId: priceId ?? null,
|
||||
},
|
||||
@ -127,12 +294,12 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
|
||||
customerType,
|
||||
productId: subscription.metadata.productId as string | undefined ?? subscription.metadata.offerId,
|
||||
priceId: priceId ?? null,
|
||||
product: productJson,
|
||||
product,
|
||||
quantity: item.quantity ?? 1,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
status: subscription.status,
|
||||
currentPeriodEnd: new Date(item.current_period_end * 1000),
|
||||
currentPeriodStart: new Date(item.current_period_start * 1000),
|
||||
currentPeriodEnd: sanitizedDates.end,
|
||||
currentPeriodStart: sanitizedDates.start,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
creationSource: "PURCHASE_PAGE"
|
||||
},
|
||||
|
||||
65
packages/stack-shared/src/plans.ts
Normal file
65
packages/stack-shared/src/plans.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Plan configuration for Stack Auth pricing tiers.
|
||||
*
|
||||
* This file defines the limits for each plan and the item IDs used to track them.
|
||||
* Import these constants in seed.ts and backend code for limit enforcement.
|
||||
*/
|
||||
|
||||
export const UNLIMITED = 1_000_000_000;
|
||||
|
||||
/**
|
||||
* Item IDs used across the codebase for tracking plan limits.
|
||||
*/
|
||||
export const ITEM_IDS = {
|
||||
seats: "dashboard_admins",
|
||||
authUsers: "auth_users",
|
||||
emailsPerMonth: "emails_per_month",
|
||||
analyticsTimeoutSeconds: "analytics_timeout_seconds",
|
||||
analyticsEvents: "analytics_events",
|
||||
} as const;
|
||||
|
||||
export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS];
|
||||
|
||||
/**
|
||||
* The offerings/limits included in a plan.
|
||||
*/
|
||||
export type PlanProductOfferings = {
|
||||
seats: number,
|
||||
authUsers: number,
|
||||
emailsPerMonth: number,
|
||||
analyticsTimeoutSeconds: number,
|
||||
analyticsEvents: number,
|
||||
};
|
||||
|
||||
/**
|
||||
* Plan limits by plan ID.
|
||||
*/
|
||||
export const PLAN_LIMITS: {
|
||||
free: PlanProductOfferings,
|
||||
team: PlanProductOfferings,
|
||||
growth: PlanProductOfferings,
|
||||
} = {
|
||||
free: {
|
||||
seats: 1,
|
||||
authUsers: 10_000,
|
||||
emailsPerMonth: 1_000,
|
||||
analyticsTimeoutSeconds: 10,
|
||||
analyticsEvents: 100_000,
|
||||
},
|
||||
team: {
|
||||
seats: 4,
|
||||
authUsers: 50_000,
|
||||
emailsPerMonth: 25_000,
|
||||
analyticsTimeoutSeconds: 60,
|
||||
analyticsEvents: 500_000,
|
||||
},
|
||||
growth: {
|
||||
seats: UNLIMITED,
|
||||
authUsers: UNLIMITED,
|
||||
emailsPerMonth: 25_000,
|
||||
analyticsTimeoutSeconds: 300,
|
||||
analyticsEvents: 1_000_000,
|
||||
},
|
||||
};
|
||||
|
||||
export type PlanId = keyof typeof PLAN_LIMITS;
|
||||
Loading…
Reference in New Issue
Block a user