From 13e91cb7645ca8095a4e65384bc3a6ba5a114a55 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 28 Jun 2026 14:32:30 +0200 Subject: [PATCH] Add subscription foundation --- .../migration.sql | 55 ++++++++++++++ backend/prisma/schema.prisma | 52 +++++++++++++ .../subscription-foundation-migration.spec.ts | 76 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 backend/prisma/migrations/20260628160000_milestone121_subscription_foundation/migration.sql create mode 100644 backend/src/modules/users/subscription-foundation-migration.spec.ts diff --git a/backend/prisma/migrations/20260628160000_milestone121_subscription_foundation/migration.sql b/backend/prisma/migrations/20260628160000_milestone121_subscription_foundation/migration.sql new file mode 100644 index 0000000..e4011dd --- /dev/null +++ b/backend/prisma/migrations/20260628160000_milestone121_subscription_foundation/migration.sql @@ -0,0 +1,55 @@ +CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'EXPIRED'); +CREATE TYPE "SubscriptionPlan" AS ENUM ('FREE', 'PRO'); +CREATE TYPE "BillingProvider" AS ENUM ('PADDLE'); + +CREATE TABLE "user_subscriptions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" "BillingProvider" NOT NULL, + "provider_customer_id" TEXT, + "provider_subscription_id" TEXT, + "plan" "SubscriptionPlan" NOT NULL, + "status" "SubscriptionStatus" NOT NULL, + "current_period_start" TIMESTAMP(3) NOT NULL, + "current_period_end" TIMESTAMP(3) NOT NULL, + "cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false, + "cancelled_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "user_subscriptions_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_subscriptions_user_id_key" +ON "user_subscriptions"("user_id"); + +CREATE UNIQUE INDEX "user_subscriptions_provider_customer_id_key" +ON "user_subscriptions"("provider_customer_id"); + +CREATE UNIQUE INDEX "user_subscriptions_provider_subscription_id_key" +ON "user_subscriptions"("provider_subscription_id"); + +CREATE TABLE "user_entitlements" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "entitlement_key" TEXT NOT NULL, + "granted" BOOLEAN NOT NULL DEFAULT false, + "source" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "user_entitlements_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "user_entitlements_user_id_entitlement_key_key" +ON "user_entitlements"("user_id", "entitlement_key"); + +ALTER TABLE "user_subscriptions" +ADD CONSTRAINT "user_subscriptions_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "user_entitlements" +ADD CONSTRAINT "user_entitlements_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 04f029a..87c4437 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -34,6 +34,8 @@ model User { incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser") initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser") accountSessions AccountSession[] + subscription UserSubscription? + entitlements UserEntitlement[] @@map("users") } @@ -302,6 +304,56 @@ model AccountSession { @@map("account_sessions") } +model UserSubscription { + id String @id @default(uuid()) @db.Uuid + userId String @unique @map("user_id") @db.Uuid + provider BillingProvider + providerCustomerId String? @unique @map("provider_customer_id") + providerSubscriptionId String? @unique @map("provider_subscription_id") + plan SubscriptionPlan + status SubscriptionStatus + currentPeriodStart DateTime @map("current_period_start") + currentPeriodEnd DateTime @map("current_period_end") + cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end") + cancelledAt DateTime? @map("cancelled_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@map("user_subscriptions") +} + +model UserEntitlement { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + entitlementKey String @map("entitlement_key") + granted Boolean @default(false) + source String + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@unique([userId, entitlementKey]) + @@map("user_entitlements") +} + +enum SubscriptionStatus { + TRIAL + ACTIVE + PAST_DUE + CANCELLED + EXPIRED +} + +enum SubscriptionPlan { + FREE + PRO +} + +enum BillingProvider { + PADDLE +} + enum UserAccountKind { LEGACY_DEFAULT GUEST diff --git a/backend/src/modules/users/subscription-foundation-migration.spec.ts b/backend/src/modules/users/subscription-foundation-migration.spec.ts new file mode 100644 index 0000000..b519950 --- /dev/null +++ b/backend/src/modules/users/subscription-foundation-migration.spec.ts @@ -0,0 +1,76 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +describe('subscription foundation migration', () => { + let migrationSql: string; + + beforeAll(async () => { + migrationSql = await readFile( + join( + process.cwd(), + 'prisma/migrations/20260628160000_milestone121_subscription_foundation/migration.sql', + ), + 'utf8', + ); + }); + + it('creates subscription and entitlement tables', () => { + expect(migrationSql).toContain(`CREATE TABLE "user_subscriptions"`); + expect(migrationSql).toContain(`CREATE TABLE "user_entitlements"`); + }); + + it('creates the subscription status, plan, and billing provider enums', () => { + expect(migrationSql).toContain( + `CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'EXPIRED')`, + ); + expect(migrationSql).toContain( + `CREATE TYPE "SubscriptionPlan" AS ENUM ('FREE', 'PRO')`, + ); + expect(migrationSql).toContain( + `CREATE TYPE "BillingProvider" AS ENUM ('PADDLE')`, + ); + }); + + it('enforces one subscription per user and unique provider identifiers', () => { + expect(migrationSql).toContain( + `CREATE UNIQUE INDEX "user_subscriptions_user_id_key"`, + ); + expect(migrationSql).toContain( + `CREATE UNIQUE INDEX "user_subscriptions_provider_customer_id_key"`, + ); + expect(migrationSql).toContain( + `CREATE UNIQUE INDEX "user_subscriptions_provider_subscription_id_key"`, + ); + }); + + it('keeps provider identifiers nullable', () => { + expect(migrationSql).toContain(`"provider_customer_id" TEXT,`); + expect(migrationSql).toContain(`"provider_subscription_id" TEXT,`); + expect(migrationSql).not.toContain( + `"provider_customer_id" TEXT NOT NULL`, + ); + expect(migrationSql).not.toContain( + `"provider_subscription_id" TEXT NOT NULL`, + ); + }); + + it('enforces entitlement uniqueness per user and key', () => { + expect(migrationSql).toContain( + `CREATE UNIQUE INDEX "user_entitlements_user_id_entitlement_key_key"`, + ); + expect(migrationSql).toContain( + `ON "user_entitlements"("user_id", "entitlement_key")`, + ); + }); + + it('relates subscriptions and entitlements to users with cascading deletes', () => { + expect(migrationSql).toContain( + `ADD CONSTRAINT "user_subscriptions_user_id_fkey"`, + ); + expect(migrationSql).toContain( + `ADD CONSTRAINT "user_entitlements_user_id_fkey"`, + ); + expect(migrationSql.match(/REFERENCES "users"\("id"\)/g)).toHaveLength(2); + expect(migrationSql.match(/ON DELETE CASCADE/g)).toHaveLength(2); + }); +});