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
2 changes: 1 addition & 1 deletion .cursor/hooks/stop-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ fi

# Run typecheck and lint
pnpm run typecheck 1>&2 || exit 2
pnpm run lint 1>&2 || exit 2
pnpm run lint --fix 1>&2 || exit 2

exit 0

Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- Code defensively. Prefer `?? throwErr(...)` over non-null assertions, with good error messages explicitly stating the assumption that must've been violated for the error to be thrown.
- Try to avoid the `any` type. Whenever you need to use `any`, leave a comment explaining why you're using it (optimally it explains why the type system fails here, and how you can be certain that any errors in that code path would still be flagged at compile-, test-, or runtime).
- Don't use Date.now() for measuring elapsed (real) time, instead use `performance.now()`
- Use urlString`` or encodeURIComponent() instead of normal string interpolation for URLs, for consistency even if it's not strictly necessary.
- When making config updates, use path notation (`{ "path.to.field": my-value }`) to avoid overwriting sibling properties
- IMPORTANT: Any assumption you make should either be validated through type system (preferred), assertions, or tests. Optimally, two out of three.
- If there is an external browser tool connected, use it to test changes you make to the frontend when possible.

### Code-related
- Use ES6 maps instead of records wherever you can.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ pnpm restart-deps
pnpm dev

# In a different terminal, run tests in watch mode
pnpm test
pnpm test # useful: --no-watch (disables watch mode) and --bail 1 (stops after the first failure)
```

You can now open the dev launchpad at [http://localhost:8100](http://localhost:8100). From there, you can navigate to the dashboard at [http://localhost:8101](http://localhost:8101), API on port 8102, demo on port 8103, docs on port 8104, Inbucket (e-mails) on port 8105, and Prisma Studio on port 8106. See the dev launchpad for a list of all running services.
Expand Down
31 changes: 17 additions & 14 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,16 @@ export async function seed() {
}
},
payments: {
catalogs: {
productLines: {
plans: {
displayName: "Plans",
}
customerType: "team",
},
},
products: {
team: {
catalogId: "plans",
displayName: "Team",
team_plans: {
productLineId: "plans",
displayName: "Team Plans",
customerType: "team",
serverOnly: false,
stackable: false,
Expand All @@ -140,7 +141,7 @@ export async function seed() {
}
},
growth: {
catalogId: "plans",
productLineId: "plans",
displayName: "Growth",
customerType: "team",
serverOnly: false,
Expand All @@ -161,7 +162,7 @@ export async function seed() {
}
},
free: {
catalogId: "plans",
productLineId: "plans",
displayName: "Free",
customerType: "team",
serverOnly: false,
Expand All @@ -176,7 +177,7 @@ export async function seed() {
}
},
"extra-admins": {
catalogId: "plans",
productLineId: "plans",
displayName: "Extra Admins",
customerType: "team",
serverOnly: false,
Expand Down Expand Up @@ -814,7 +815,7 @@ function buildDummyPaymentsSetup(): PaymentsSetup {
const paymentsProducts = {
'starter': {
displayName: 'Starter',
catalogId: 'workspace',
productLineId: 'workspace',
customerType: 'user',
serverOnly: false,
stackable: false,
Expand Down Expand Up @@ -842,7 +843,7 @@ function buildDummyPaymentsSetup(): PaymentsSetup {
},
'growth': {
displayName: 'Growth',
catalogId: 'workspace',
productLineId: 'workspace',
customerType: 'user',
serverOnly: false,
stackable: false,
Expand Down Expand Up @@ -878,7 +879,7 @@ function buildDummyPaymentsSetup(): PaymentsSetup {
},
'regression-addon': {
displayName: 'Regression Add-on',
catalogId: 'add_ons',
productLineId: 'add_ons',
customerType: 'user',
serverOnly: false,
stackable: true,
Expand All @@ -905,12 +906,14 @@ function buildDummyPaymentsSetup(): PaymentsSetup {

const paymentsOverride = {
testMode: true,
catalogs: {
productLines: {
workspace: {
displayName: 'Workspace Plans',
customerType: 'team',
},
add_ons: {
displayName: 'Add-ons',
customerType: 'team',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seed data has mismatched customerType between products and productLines

Medium Severity

The productLines in buildDummyPaymentsSetup() are defined with customerType: 'team', but all products referencing them (starter, growth, regression-addon) have customerType: 'user'. Similarly, the legacy subscription uses productLineId: 'workspace' with customerType: 'user', and the one-time purchase uses productLineId: 'add_ons' with customerType: 'custom'. The UI enforces that products can only belong to product lines with matching customerType, so this creates an invalid state that wouldn't occur through normal operations and could cause products to not appear under their product lines.

Additional Locations (2)

Fix in Cursor Fix in Web

},
},
items: {
Expand Down Expand Up @@ -1233,7 +1236,7 @@ async function seedDummyTransactions(options: TransactionsSeedOptions) {
priceId: undefined,
product: cloneJson({
displayName: 'Legacy Enterprise Pilot',
catalogId: 'workspace',
productLineId: 'workspace',
customerType: 'user',
prices: 'include-by-default',
}),
Expand Down Expand Up @@ -1408,7 +1411,7 @@ async function seedDummyTransactions(options: TransactionsSeedOptions) {
priceId: 'one_time',
product: cloneJson({
displayName: 'Design Audit Pass',
catalogId: 'add_ons',
productLineId: 'add_ons',
customerType: 'custom',
prices: {
one_time: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ export const GET = createSmartRouteHandler({
? ownedProducts.filter(({ product }) => !product.serverOnly)
: ownedProducts;

const switchOptionsByCatalogId = new Map<string, Array<{ product_id: string, product: ReturnType<typeof productToInlineProduct> }>>();
const switchOptionsByProductLineId = new Map<string, Array<{ product_id: string, product: ReturnType<typeof productToInlineProduct> }>>();

const configuredProducts = auth.tenancy.config.payments.products;
for (const [productId, product] of typedEntries(configuredProducts)) {
if (product.customerType !== params.customer_type) continue;
if (auth.type === "client" && product.serverOnly) continue;
if (!product.catalogId) continue;
if (!product.productLineId) continue;
if (product.prices === "include-by-default") continue;
const hasIntervalPrice = typedEntries(product.prices).some(([, price]) => price.interval);
if (!hasIntervalPrice) continue;
Expand All @@ -64,19 +64,19 @@ export const GET = createSmartRouteHandler({
);
if (typedEntries(intervalPrices).length === 0) continue;

const existing = switchOptionsByCatalogId.get(product.catalogId) ?? [];
const existing = switchOptionsByProductLineId.get(product.productLineId) ?? [];
existing.push({ product_id: productId, product: { ...inlineProduct, prices: intervalPrices } });
switchOptionsByCatalogId.set(product.catalogId, existing);
switchOptionsByProductLineId.set(product.productLineId, existing);
}

const sorted = visibleProducts
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((product) => {
const catalogId = product.product.catalogId;
const productLineId = product.product.productLineId;
const switchOptions =
product.type === "subscription" && product.id && catalogId
? (switchOptionsByCatalogId.get(catalogId) ?? []).filter((option) => option.product_id !== product.id)
product.type === "subscription" && product.id && productLineId
? (switchOptionsByProductLineId.get(productLineId) ?? []).filter((option) => option.product_id !== product.id)
: undefined;

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ export const POST = createSmartRouteHandler({
if (fromProduct.customerType !== params.customer_type || toProduct.customerType !== params.customer_type) {
throw new StatusError(400, "Product customer type does not match.");
}
if (!fromProduct.catalogId || fromProduct.catalogId !== toProduct.catalogId) {
throw new StatusError(400, "Products must be in the same catalog to switch.");
if (!fromProduct.productLineId || fromProduct.productLineId !== toProduct.productLineId) {
throw new StatusError(400, "Products must be in the same product line to switch.");
}
if (body.from_product_id === body.to_product_id) {
throw new StatusError(400, "Product is already active.");
Expand Down Expand Up @@ -93,12 +93,12 @@ export const POST = createSmartRouteHandler({
customerId: params.customer_id,
productId: body.to_product_id,
});
const hasOneTimeInCatalog = existingOneTimePurchases.some((purchase) => {
const hasOneTimeInProductLine = existingOneTimePurchases.some((purchase) => {
const product = purchase.product as typeof toProduct;
return product.catalogId === fromProduct.catalogId;
return product.productLineId === fromProduct.productLineId;
});
if (hasOneTimeInCatalog) {
throw new StatusError(400, "Customer already has a one-time purchase in this product catalog");
if (hasOneTimeInProductLine) {
throw new StatusError(400, "Customer already has a one-time purchase in this product line");
}

let subscription = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const POST = createSmartRouteHandler({
}
const stripe = await getStripeForAccount({ accountId: data.stripeAccountId });
const prisma = await getPrismaClientForTenancy(tenancy);
const { selectedPrice, conflictingCatalogSubscriptions } = await validatePurchaseSession({
const { selectedPrice, conflictingProductLineSubscriptions } = await validatePurchaseSession({
prisma,
tenancy,
codeData: data,
Expand All @@ -73,8 +73,8 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("Price not resolved for purchase session");
}

if (conflictingCatalogSubscriptions.length > 0) {
const conflicting = conflictingCatalogSubscriptions[0];
if (conflictingProductLineSubscriptions.length > 0) {
const conflicting = conflictingProductLineSubscriptions[0];
if (conflicting.stripeSubscriptionId) {
const existingStripeSub = await stripe.subscriptions.retrieve(conflicting.stripeSubscriptionId);
const existingItem = existingStripeSub.items.data[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,20 +70,20 @@ export const POST = createSmartRouteHandler({

const alreadyBoughtNonStackable = !!(subscriptions.find((s) => s.productId === verificationCode.data.productId) && product.stackable !== true);

const catalogs = tenancy.config.payments.catalogs;
const catalogId = Object.keys(catalogs).find((g) => product.catalogId === g);
let conflictingCatalogProducts: { product_id: string, display_name: string }[] = [];
if (catalogId) {
const productLines = tenancy.config.payments.productLines;
const productLineId = Object.keys(productLines).find((g) => product.productLineId === g);
let conflictingProductLineProducts: { product_id: string, display_name: string }[] = [];
if (productLineId) {
const isSubscribable = product.prices !== "include-by-default" && Object.values(product.prices).some((p: any) => p && p.interval);
if (isSubscribable) {
const conflicts = subscriptions.filter((subscription) => (
subscription.productId &&
subscription.product.catalogId === catalogId &&
subscription.product.productLineId === productLineId &&
isActiveSubscription(subscription) &&
subscription.product.prices !== "include-by-default" &&
(!product.isAddOnTo || !Object.keys(product.isAddOnTo).includes(subscription.productId))
));
conflictingCatalogProducts = conflicts.map((s) => ({
conflictingProductLineProducts = conflicts.map((s) => ({
product_id: s.productId!,
display_name: s.product.displayName ?? s.productId!,
}));
Expand All @@ -99,7 +99,7 @@ export const POST = createSmartRouteHandler({
project_id: tenancy.project.id,
project_logo_url: tenancy.project.logo_url ?? null,
already_bought_non_stackable: alreadyBoughtNonStackable,
conflicting_products: conflictingCatalogProducts,
conflicting_products: conflictingProductLineProducts,
test_mode: tenancy.config.payments.testMode === true,
charges_enabled: verificationCode.data.chargesEnabled,
},
Expand Down
Loading
Loading