diff --git a/packages/api/src/routers/helpers/tenant-limit.ts b/packages/api/src/routers/helpers/tenant-limit.ts index dc6a5aa6..fad683d3 100644 --- a/packages/api/src/routers/helpers/tenant-limit.ts +++ b/packages/api/src/routers/helpers/tenant-limit.ts @@ -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 @@ -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 { + 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 { - 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; } /** @@ -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.`, }); @@ -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.`, }); diff --git a/packages/api/src/routers/test/subscription-entitlement.test.ts b/packages/api/src/routers/test/subscription-entitlement.test.ts new file mode 100644 index 00000000..744b462c --- /dev/null +++ b/packages/api/src/routers/test/subscription-entitlement.test.ts @@ -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); + }); +}); diff --git a/packages/db/src/schema/migrations/0045_subscription_entitlement.sql b/packages/db/src/schema/migrations/0045_subscription_entitlement.sql new file mode 100644 index 00000000..6c203efd --- /dev/null +++ b/packages/db/src/schema/migrations/0045_subscription_entitlement.sql @@ -0,0 +1,104 @@ +-- Subscription entitlement enforcement: the D04 seat guard and the D07 +-- pending-invite quota read the plan limit from the owner's latest +-- subscription, but they ignored the subscription's lifecycle. A cancelled or +-- lapsed subscription kept granting its full tenant limit, so any pause/cancel +-- control would have been a dead button. +-- +-- Entitlement is TIME-based, not status-based: a subscription grants its plan +-- limit while it has not been flagged `expired` AND its paid period has not +-- lapsed (`current_period_end` is null or still in the future). Cancellation +-- keeps `current_period_end`, so access survives to the end of the paid period +-- and lapses afterwards. `subscriptions.current_period_end` is a zone-less +-- `timestamp` holding UTC wall clock, so compare against `now() at time zone +-- 'utc'` — the same convention D03 uses for renewal math. +-- +-- A lapsed subscription yields a limit of 0, which makes `v_active >= 0` true +-- and raises the existing refusal code. An owner with NO subscription row +-- still falls back to the literal 10 (TENANT_LIMIT in +-- packages/db/src/constants/payment-constants.ts), preserving first-run +-- onboarding. The TypeScript read in routers/helpers/tenant-limit.ts mirrors +-- these predicates; this SQL arbiter stays authoritative. +CREATE OR REPLACE FUNCTION rently_assert_tenant_seat(p_owner_id uuid, p_tenant_id uuid) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + v_limit integer; + v_active integer; +BEGIN + PERFORM pg_advisory_xact_lock( + hashtextextended('rently:tenant-limit:' || p_owner_id::text, 0) + ); + + SELECT + CASE + WHEN s."expired" IS NOT TRUE + AND ( + s."current_period_end" IS NULL + OR s."current_period_end" > (now() AT TIME ZONE 'utc') + ) + THEN plans.tenant_limit + ELSE 0 + END INTO v_limit + FROM subscriptions s + JOIN plans ON plans.id = s.plan_id + WHERE s.user_id = p_owner_id + ORDER BY s.created_at DESC, s.id DESC + LIMIT 1; + + SELECT count(DISTINCT l.tenant_id)::int INTO v_active + FROM leases l + JOIN units u ON u.id = l.unit_id + JOIN properties p ON p.id = u.property_id + WHERE p.owner_id = p_owner_id + AND l.status = 'active' + AND l.tenant_id <> p_tenant_id; + + IF v_active >= coalesce(v_limit, 10) THEN + RAISE EXCEPTION 'TENANT_PLAN_LIMIT_REACHED' + USING ERRCODE = 'P0340'; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION rently_assert_pending_invite_quota(p_owner_id uuid) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + v_limit integer; + v_pending integer; +BEGIN + PERFORM pg_advisory_xact_lock( + hashtextextended('rently:pending-invite-limit:' || p_owner_id::text, 0) + ); + + SELECT + CASE + WHEN s."expired" IS NOT TRUE + AND ( + s."current_period_end" IS NULL + OR s."current_period_end" > (now() AT TIME ZONE 'utc') + ) + THEN plans.tenant_limit + ELSE 0 + END INTO v_limit + FROM subscriptions s + JOIN plans ON plans.id = s.plan_id + WHERE s.user_id = p_owner_id + ORDER BY s.created_at DESC, s.id DESC + LIMIT 1; + + SELECT count(*)::int INTO v_pending + FROM tenant_invites + WHERE invited_by = p_owner_id + AND status = 'pending' + AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at > now()); + + IF v_pending >= coalesce(v_limit, 10) THEN + RAISE EXCEPTION 'PENDING_INVITE_LIMIT_REACHED' + USING ERRCODE = 'P0341'; + END IF; +END; +$$; diff --git a/packages/db/src/schema/migrations/meta/_journal.json b/packages/db/src/schema/migrations/meta/_journal.json index bb90d0e3..25c8ce07 100644 --- a/packages/db/src/schema/migrations/meta/_journal.json +++ b/packages/db/src/schema/migrations/meta/_journal.json @@ -316,6 +316,13 @@ "when": 1788789128716, "tag": "0044_opposite_tony_stark", "breakpoints": true + }, + { + "idx": 45, + "version": "7", + "when": 1788791000000, + "tag": "0045_subscription_entitlement", + "breakpoints": true } ] } \ No newline at end of file