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
83 changes: 70 additions & 13 deletions packages/api/src/routers/helpers/tenant-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ORPCError } from "@orpc/server";
import type { Database } from "@rently/db";
import { TENANT_LIMIT } from "@rently/db/constants/payment-constants";
import { plans, subscriptions } from "@rently/db/schema/subscription";
import { desc, eq, type SQL, sql } from "drizzle-orm";
import { type SQL, sql } from "drizzle-orm";

// D04: tenant-plan-limit enforcement. A "seat" is a distinct tenant with an
// active lease under the owner's properties (the count previously read by
Expand All @@ -11,20 +11,66 @@ import { desc, eq, type SQL, sql } from "drizzle-orm";
// and are freed automatically because the count is derived from live rows,
// never materialized.

/** The owner's plan tenant limit; TENANT_LIMIT when no subscription exists. */
export type OwnerEntitlement = {
/** False once the paid period lapses or the subscription is marked expired. */
entitled: boolean;
/** The plan limit while entitled; 0 once lapsed. */
tenantLimit: number;
};

/**
* The owner's effective subscription entitlement. Entitlement is TIME-based:
* the latest subscription grants its plan limit while it is not `expired` and
* its paid period has not lapsed. Cancellation keeps `current_period_end`, so
* access survives to the end of the paid period. An owner with no subscription
* row keeps the legacy TENANT_LIMIT fallback, so onboarding is unchanged.
*
* The predicates are compared in SQL against `now() at time zone 'utc'`
* because `subscriptions.current_period_end` is a zone-less `timestamp`
* holding UTC wall clock (D03 convention). Migration 0045 mirrors these exact
* predicates in `rently_assert_tenant_seat` /
* `rently_assert_pending_invite_quota`; those guards, not this read, arbitrate.
*/
export async function getOwnerEntitlement(
db: Database,
ownerId: string,
): Promise<OwnerEntitlement> {
const result = await db.execute<{
entitled: boolean;
tenant_limit: number | null;
}>(sql`
SELECT
(
s."expired" IS NOT TRUE
AND (
s."current_period_end" IS NULL
OR s."current_period_end" > (now() AT TIME ZONE 'utc')
)
) AS "entitled",
p."tenant_limit" AS "tenant_limit"
FROM ${subscriptions} s
JOIN ${plans} p ON p."id" = s."plan_id"
WHERE s."user_id" = ${ownerId}
ORDER BY s."created_at" DESC, s."id" DESC
LIMIT 1
`);

const row = result.rows[0];
if (!row) return { entitled: true, tenantLimit: TENANT_LIMIT };

const entitled = Boolean(row.entitled);
return {
entitled,
tenantLimit: entitled ? (row.tenant_limit ?? TENANT_LIMIT) : 0,
};
}

/** The owner's plan tenant limit; 0 once the subscription has lapsed. */
export async function getOwnerTenantLimit(
db: Database,
ownerId: string,
): Promise<number> {
const [subRow] = await db
.select({ tenantLimit: plans.tenantLimit })
.from(subscriptions)
.innerJoin(plans, eq(subscriptions.planId, plans.id))
.where(eq(subscriptions.userId, ownerId))
.orderBy(desc(subscriptions.createdAt))
.limit(1);

return subRow?.tenantLimit ?? TENANT_LIMIT;
return (await getOwnerEntitlement(db, ownerId)).tenantLimit;
}

/**
Expand Down Expand Up @@ -63,8 +109,18 @@ export function isPendingInviteQuotaError(error: unknown): boolean {
return false;
}

// One refusal for a lapsed subscription, so a cancelled owner reads a
// renewal prompt instead of a nonsensical "plan limit of 0".
function subscriptionLapsedError() {
return new ORPCError("FORBIDDEN", {
message:
"Your subscription has ended. Renew your plan to keep managing tenants.",
});
}

export async function pendingInviteQuotaError(db: Database, ownerId: string) {
const tenantLimit = await getOwnerTenantLimit(db, ownerId);
const { entitled, tenantLimit } = await getOwnerEntitlement(db, ownerId);
if (!entitled) return subscriptionLapsedError();
return new ORPCError("FORBIDDEN", {
message: `You've reached your plan limit of ${tenantLimit} pending invitation${tenantLimit === 1 ? "" : "s"}. Revoke one or wait for expiry before inviting again.`,
});
Expand Down Expand Up @@ -95,7 +151,8 @@ export function isTenantPlanLimitError(error: unknown): boolean {

/** The FORBIDDEN activation refusal, worded like the shipped invite check. */
export async function tenantPlanLimitError(db: Database, ownerId: string) {
const tenantLimit = await getOwnerTenantLimit(db, ownerId);
const { entitled, tenantLimit } = await getOwnerEntitlement(db, ownerId);
if (!entitled) return subscriptionLapsedError();
return new ORPCError("FORBIDDEN", {
message: `You've reached your plan limit of ${tenantLimit} active tenant${tenantLimit === 1 ? "" : "s"}. Upgrade to Pro to add more.`,
});
Expand Down
259 changes: 259 additions & 0 deletions packages/api/src/routers/test/subscription-entitlement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { createDb } from "@rently/db";
import {
PLAN_STATUS,
TENANT_LIMIT,
} from "@rently/db/constants/payment-constants";
import { user } from "@rently/db/schema/auth";
import { plans, subscriptions } from "@rently/db/schema/subscription";
import { inArray } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";

// Regression rationale (Fix-Plan: subscription entitlement enforcement):
// D04/D07 read the tenant limit from the latest subscription but ignored the
// subscription's lifecycle, so a cancelled or lapsed owner kept full plan
// access and any cancel control would have been a dead button. These tests pin
// the time-based entitlement rule that both the TypeScript read and the SQL
// guards (migration 0045) must agree on:
// - no subscription row keeps the legacy TENANT_LIMIT fallback,
// - active/trial with no end (or a future end) is entitled,
// - cancelled with a FUTURE end stays entitled until the paid period ends
// (cancel-at-period-end semantics),
// - cancelled with a PAST end, or `expired = true`, is not entitled and the
// seat/invite guards refuse (P0340/P0341) so activation cannot slip
// through the SQL arbiter,
// - status alone is not the boundary: a paused subscription inside its paid
// period stays entitled.

import {
assertPendingInviteQuotaSql,
assertTenantSeatSql,
getOwnerEntitlement,
getOwnerTenantLimit,
isPendingInviteQuotaError,
isTenantPlanLimitError,
tenantPlanLimitError,
} from "../helpers/tenant-limit";

const db = createDb();

const createdUserIds: string[] = [];
const createdPlanIds: string[] = [];
const createdSubscriptionIds: string[] = [];

async function createOwner() {
const id = crypto.randomUUID();
createdUserIds.push(id);
await db.insert(user).values({
id,
name: "Entitlement Owner",
email: `${id}@entitlement.keyhq.invalid`,
role: "owner",
});
return id;
}

async function createPlan(tenantLimit: number) {
const id = crypto.randomUUID();
createdPlanIds.push(id);
await db.insert(plans).values({
id,
name: "Entitlement Plan",
slug: `entitlement-${id}`,
tenantLimit,
priceMonthly: 0,
});
return id;
}

async function createSubscription(
ownerId: string,
planId: string,
overrides: {
status?: (typeof PLAN_STATUS)[keyof typeof PLAN_STATUS];
currentPeriodEnd?: Date | null;
expired?: boolean;
} = {},
) {
const id = crypto.randomUUID();
createdSubscriptionIds.push(id);
await db.insert(subscriptions).values({
id,
userId: ownerId,
planId,
status: overrides.status ?? PLAN_STATUS.ACTIVE,
currentPeriodEnd: overrides.currentPeriodEnd ?? null,
expired: overrides.expired ?? false,
});
return id;
}

const PAST = new Date("2020-01-01T00:00:00.000Z");
const FUTURE = new Date("2035-01-01T00:00:00.000Z");

afterEach(async () => {
if (createdSubscriptionIds.length > 0) {
await db
.delete(subscriptions)
.where(inArray(subscriptions.id, createdSubscriptionIds));
}
if (createdUserIds.length > 0) {
await db.delete(user).where(inArray(user.id, createdUserIds));
}
if (createdPlanIds.length > 0) {
await db.delete(plans).where(inArray(plans.id, createdPlanIds));
}
createdSubscriptionIds.length = 0;
createdUserIds.length = 0;
createdPlanIds.length = 0;
});

describe("owner subscription entitlement", () => {
it("keeps the TENANT_LIMIT fallback for an owner without a subscription", async () => {
const ownerId = await createOwner();
const entitlement = await getOwnerEntitlement(db, ownerId);
expect(entitlement).toEqual({
entitled: true,
tenantLimit: TENANT_LIMIT,
});
expect(await getOwnerTenantLimit(db, ownerId)).toBe(TENANT_LIMIT);
});

it("grants the plan limit for an active subscription with no period end", async () => {
const ownerId = await createOwner();
const planId = await createPlan(3);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.ACTIVE,
currentPeriodEnd: null,
});

expect(await getOwnerEntitlement(db, ownerId)).toEqual({
entitled: true,
tenantLimit: 3,
});
});

it("grants the plan limit inside a paid period", async () => {
const ownerId = await createOwner();
const planId = await createPlan(7);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.ACTIVE,
currentPeriodEnd: FUTURE,
});

expect(await getOwnerEntitlement(db, ownerId)).toEqual({
entitled: true,
tenantLimit: 7,
});
});

it("keeps a cancelled-at-period-end owner entitled until the paid period ends", async () => {
const ownerId = await createOwner();
const planId = await createPlan(5);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.CANCELLED,
currentPeriodEnd: FUTURE,
});

// The whole point of cancel-at-period-end: status flips, access does not.
expect(await getOwnerEntitlement(db, ownerId)).toEqual({
entitled: true,
tenantLimit: 5,
});
});

it("denies a cancellation whose paid period has lapsed", async () => {
const ownerId = await createOwner();
const planId = await createPlan(5);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.CANCELLED,
currentPeriodEnd: PAST,
});

expect(await getOwnerEntitlement(db, ownerId)).toEqual({
entitled: false,
tenantLimit: 0,
});
});

it("denies a subscription marked expired even inside its paid period", async () => {
const ownerId = await createOwner();
const planId = await createPlan(9);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.ACTIVE,
currentPeriodEnd: FUTURE,
expired: true,
});

expect(await getOwnerEntitlement(db, ownerId)).toEqual({
entitled: false,
tenantLimit: 0,
});
});

it("treats a paused subscription inside its paid period as entitled", async () => {
const ownerId = await createOwner();
const planId = await createPlan(4);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.PAUSED,
currentPeriodEnd: FUTURE,
});

// Entitlement is time-based by design; pause is not itself a boundary.
expect(await getOwnerEntitlement(db, ownerId)).toEqual({
entitled: true,
tenantLimit: 4,
});
});
});

describe("SQL entitlement arbiters", () => {
it("refuses a seat for a lapsed subscription and maps to the activation refusal", async () => {
const ownerId = await createOwner();
const planId = await createPlan(5);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.CANCELLED,
currentPeriodEnd: PAST,
});

const error = await db
.execute(assertTenantSeatSql(ownerId, crypto.randomUUID()))
.then(() => null)
.catch((caught: unknown) => caught);

expect(error).not.toBeNull();
expect(isTenantPlanLimitError(error)).toBe(true);
await expect(tenantPlanLimitError(db, ownerId)).resolves.toMatchObject({
message: expect.stringContaining("subscription has ended"),
});
});

it("still allows a seat for an active subscription within its paid period", async () => {
const ownerId = await createOwner();
const planId = await createPlan(5);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.ACTIVE,
currentPeriodEnd: FUTURE,
});

await expect(
db.execute(assertTenantSeatSql(ownerId, crypto.randomUUID())),
).resolves.toBeDefined();
});

it("refuses the invite quota for a lapsed subscription and maps to the pending refusal", async () => {
const ownerId = await createOwner();
const planId = await createPlan(5);
await createSubscription(ownerId, planId, {
status: PLAN_STATUS.CANCELLED,
currentPeriodEnd: PAST,
});

const error = await db
.execute(assertPendingInviteQuotaSql(ownerId))
.then(() => null)
.catch((caught: unknown) => caught);

expect(error).not.toBeNull();
expect(isPendingInviteQuotaError(error)).toBe(true);
});
});
Loading
Loading