@saasicat/spec 1.0.0-rc.5 → 1.0.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,5 +83,6 @@ pnpm add @saasicat/spec
83
83
  ## Next
84
84
 
85
85
  - [Data model](../../docs/explanation/data-model.md) — the tables the fragments declare
86
- - [From capability to contract](../../docs/explanation/capability-to-contract.md) — what the schemas describe
86
+ - [From capability to contract](../../docs/explanation/capability-to-contract.md) — what the schemas
87
+ describe
87
88
  - [Codegen](../../docs/explanation/adr/0006-spec-to-types-codegen.md) — how types are derived from them
@@ -29,7 +29,7 @@ openapi: 3.1.0
29
29
 
30
30
  info:
31
31
  title: SaaS Platform SuperAdmin API
32
- version: 1.0.0-rc.5
32
+ version: 1.0.0-rc.7
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
@@ -1391,10 +1391,8 @@ components:
1391
1391
  # ────────── Catalog V2 (DTO = SSOT: packages/nest/src/catalog/dto/*) ──────────
1392
1392
  CatalogPlanCreate:
1393
1393
  type: object
1394
- required: [projectKey, planKey, label]
1394
+ required: [planKey, label]
1395
1395
  properties:
1396
- projectKey:
1397
- { type: string, pattern: '^[a-z][a-z0-9-]*$', description: 'kebab-case' }
1398
1396
  planKey:
1399
1397
  {
1400
1398
  type: string,
@@ -1435,9 +1433,8 @@ components:
1435
1433
 
1436
1434
  CatalogBundleCreate:
1437
1435
  type: object
1438
- required: [projectKey, bundleKey, label]
1436
+ required: [bundleKey, label]
1439
1437
  properties:
1440
- projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1441
1438
  bundleKey: { type: string, pattern: '^[A-Z][A-Z0-9_]*$', description: 'no hyphen' }
1442
1439
  label: { type: string, minLength: 1, maxLength: 120 }
1443
1440
  description: { type: string, maxLength: 2000 }
@@ -1495,9 +1492,8 @@ components:
1495
1492
 
1496
1493
  CatalogMarketingProjectionCreate:
1497
1494
  type: object
1498
- required: [projectKey, targetType, targetVersionId, displayLabel, description]
1495
+ required: [targetType, targetVersionId, displayLabel, description]
1499
1496
  properties:
1500
- projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1501
1497
  targetType: { type: string, enum: [PLAN, BUNDLE] }
1502
1498
  targetVersionId: { type: string }
1503
1499
  locale: { type: string, pattern: '^[a-z]{2}(-[A-Z]{2})?$', default: de }
@@ -1613,9 +1609,8 @@ components:
1613
1609
 
1614
1610
  CatalogMarketingSettingsUpdate:
1615
1611
  type: object
1616
- required: [projectKey, activeLocales]
1612
+ required: [activeLocales]
1617
1613
  properties:
1618
- projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1619
1614
  activeLocales:
1620
1615
  type: array
1621
1616
  uniqueItems: true
@@ -1623,9 +1618,8 @@ components:
1623
1618
 
1624
1619
  CatalogPromotionCreate:
1625
1620
  type: object
1626
- required: [projectKey, internalLabel, type, value, validFrom, validTo]
1621
+ required: [internalLabel, type, value, validFrom, validTo]
1627
1622
  properties:
1628
- projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1629
1623
  internalLabel: { type: string, maxLength: 120 }
1630
1624
  type: { type: string, enum: [percent, amount, intro, freeMonths] }
1631
1625
  value:
@@ -1698,10 +1692,15 @@ components:
1698
1692
  # ────────── Plan-Catalog (mirror of plans.yaml) ──────────
1699
1693
  PlanCatalog:
1700
1694
  type: object
1701
- required: [schemaVersion, projectKey, currency, vatRate, plans]
1695
+ required: [schemaVersion, app, currency, vatRate, plans]
1702
1696
  properties:
1703
1697
  schemaVersion: { type: integer, const: 1 }
1704
- projectKey: { type: string, description: 'Project key of the app (kebab-case)' }
1698
+ app:
1699
+ type: object
1700
+ required: [name]
1701
+ description: 'App identity — the only place the application is named'
1702
+ properties:
1703
+ name: { type: string, minLength: 1 }
1705
1704
  currency: { type: string, pattern: '^[A-Z]{3}$' }
1706
1705
  vatRate: { type: number, format: decimal, description: 'in percent' }
1707
1706
  features:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.5",
3
+ "version": "1.0.0-rc.7",
4
4
  "description": "Language-neutral spec of the SaaS platform: JSON Schemas, OpenAPI contract, Prisma fragments, acceptance scenarios.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -38,6 +38,7 @@
38
38
  "devDependencies": {
39
39
  "ajv": "^8.17.1",
40
40
  "ajv-formats": "^3.0.1",
41
+ "pg": "^8.22.0",
41
42
  "prisma": "^6.19.0"
42
43
  },
43
44
  "keywords": [
@@ -63,6 +64,7 @@
63
64
  },
64
65
  "scripts": {
65
66
  "gen:sql": "node scripts/generate-reference-sql.mjs",
66
- "test": "node --test tests/*.test.js"
67
+ "test": "node --test tests/*.test.js",
68
+ "test:integration": "node --test 'tests/integration/*.test.js'"
67
69
  }
68
70
  }
@@ -61,12 +61,42 @@ model Subscription {
61
61
 
62
62
  trialEndsAt DateTime?
63
63
  startedAt DateTime?
64
- canceledAt DateTime?
64
+ // WHEN the customer said they want out. Never refused: a cancellation is a
65
+ // declaration, and `canceledEffectiveAt` is when it lands. The two are
66
+ // separate because a subscription cancelled in month three of a year is
67
+ // still running, still billed and still entitled until the term ends —
68
+ // rendering it as "cancelled, gone" is how a tenant loses access they paid
69
+ // for. `SubscriptionBundle` already models it this way.
70
+ canceledAt DateTime?
71
+ canceledEffectiveAt DateTime?
65
72
 
66
73
  // Renewal period (maintained by PlanVersionRenewalService).
67
74
  currentPeriodStart DateTime?
68
75
  currentPeriodEnd DateTime?
69
76
 
77
+ // The end of what was committed to, which is not always the end of the
78
+ // period. They coincide while nothing changes, and part company the moment
79
+ // a notice period is configured: a cancellation declared after the window
80
+ // closed lands at the end of the FOLLOWING period, and this is where that
81
+ // date is kept rather than recomputed by whoever asks.
82
+ //
83
+ // Null means no commitment — a trial, or a subscription that predates this
84
+ // field. Readers treat null as "the period end is the answer".
85
+ minimumTermUntil DateTime?
86
+
87
+ // The day of the month this subscription is billed on, 1–31.
88
+ //
89
+ // Written when a period window is opened — at activation, and again at a
90
+ // plan change that resets the window — and never rewritten by a renewal.
91
+ // That is the whole point: a renewal reads it, and reading its own previous
92
+ // result is how the day used to drift. A subscription starting on the 31st
93
+ // was billed on the 28th from its first February onwards.
94
+ //
95
+ // Null on a row that predates this column, and on one with no period at
96
+ // all. Readers fall back to the day of whichever date opened the window,
97
+ // which is the old behaviour for one step and correct for that step.
98
+ billingAnchorDay Int?
99
+
70
100
  // Plan versioning — binds to a published PlanVersion.
71
101
  // `pendingPlanVersion*` concern a version change within the
72
102
  // same plan; `pendingPlan` the change to a different plan. Both
@@ -171,8 +201,7 @@ model SubscriptionPaymentMethod {
171
201
  // -----------------------------------------------------------------------------
172
202
 
173
203
  model CheckoutOffer {
174
- id String @id @default(uuid())
175
- projectKey String
204
+ id String @id @default(uuid())
176
205
 
177
206
  planKey String
178
207
  planVersionId String?
@@ -197,6 +226,6 @@ model CheckoutOffer {
197
226
  createdAt DateTime @default(now())
198
227
  updatedAt DateTime @updatedAt
199
228
 
200
- @@index([projectKey, status])
229
+ @@index([status])
201
230
  @@map("checkout_offers")
202
231
  }
@@ -20,7 +20,7 @@
20
20
  // -----------------------------------------------------------------------------
21
21
  // Plan — root identity of a plan (SPEC_V2 §11.1 M6).
22
22
  // `planKey` is the business plan identity (STARTER / STANDARD / PROFESSIONAL),
23
- // unique per `projectKey`. PlanVersion.planId points **softly** to
23
+ // unique for the installation. PlanVersion.planId points **softly** to
24
24
  // `Plan.planKey` (no DB FK — the binding is enforced via service logic in M6
25
25
  // so that the greenfield cutover can run without a destructive schema
26
26
  // migration).
@@ -32,7 +32,6 @@
32
32
 
33
33
  model Plan {
34
34
  id String @id @default(uuid())
35
- projectKey String
36
35
  planKey String
37
36
  label String
38
37
  description String?
@@ -43,8 +42,8 @@ model Plan {
43
42
  updatedAt DateTime @updatedAt
44
43
  deletedAt DateTime?
45
44
 
46
- @@unique([projectKey, planKey])
47
- @@index([projectKey, deletedAt])
45
+ @@unique([planKey])
46
+ @@index([deletedAt])
48
47
  @@map("plans")
49
48
  }
50
49
 
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Global catalog tables (no tenantId, no RLS). Write access only via the
8
8
  // SUPER_ADMIN guard in the AdminController of the `@saasicat/nest`
9
- // package. Entries are assigned to the respective consumer app via
10
- // `projectKey`.
9
+ // package. One installation serves one application, so `bundleKey` is the
10
+ // whole identity.
11
11
  //
12
12
  // Versioning pattern identical to PlanVersion:
13
13
  // - at most 1 draft (publishedAt IS NULL) per identity — constraint via a
@@ -25,7 +25,6 @@
25
25
 
26
26
  model Bundle {
27
27
  id String @id @default(uuid())
28
- projectKey String // e.g. 'clubapp' | 'demoapp' | …
29
28
  bundleKey String // SCREAMING_SNAKE_CASE, e.g. 'SPORT', 'ACCOUNTING', 'EVERSEND_PEPPOL'
30
29
  label String
31
30
  description String?
@@ -41,8 +40,8 @@ model Bundle {
41
40
 
42
41
  versions BundleVersion[]
43
42
 
44
- @@unique([projectKey, bundleKey])
45
- @@index([projectKey, deletedAt])
43
+ @@unique([bundleKey])
44
+ @@index([deletedAt])
46
45
  @@map("bundles")
47
46
  }
48
47
 
@@ -42,7 +42,6 @@
42
42
 
43
43
  model CapabilityCatalogEntry {
44
44
  id String @id @default(uuid())
45
- projectKey String // e.g. 'clubapp' | 'demoapp' | …
46
45
  capabilityKey String // 'invoice.create' | 'member.archive' | …
47
46
  label String
48
47
  description String?
@@ -77,9 +76,9 @@ model CapabilityCatalogEntry {
77
76
  updatedAt DateTime @updatedAt
78
77
  deletedAt DateTime?
79
78
 
80
- @@unique([projectKey, capabilityKey])
81
- @@index([projectKey, codeStatus])
82
- @@index([projectKey, featureKey])
79
+ @@unique([capabilityKey])
80
+ @@index([codeStatus])
81
+ @@index([featureKey])
83
82
  @@map("capability_catalog_entries")
84
83
  }
85
84
 
@@ -100,7 +99,6 @@ model CapabilityCatalogEntry {
100
99
 
101
100
  model FeatureCatalogEntry {
102
101
  id String @id @default(uuid())
103
- projectKey String
104
102
  featureKey String // 'INVOICE_MANAGEMENT' | 'MEMBER_LIST' | …
105
103
  label String
106
104
  description String?
@@ -148,9 +146,9 @@ model FeatureCatalogEntry {
148
146
  updatedAt DateTime @updatedAt
149
147
  deletedAt DateTime?
150
148
 
151
- @@unique([projectKey, featureKey])
152
- @@index([projectKey, discoveryStatus])
153
- @@index([projectKey, plannedOnly])
149
+ @@unique([featureKey])
150
+ @@index([discoveryStatus])
151
+ @@index([plannedOnly])
154
152
  @@map("feature_catalog_entries")
155
153
  }
156
154
 
@@ -164,7 +162,6 @@ model FeatureCatalogEntry {
164
162
 
165
163
  model QuotaCatalogEntry {
166
164
  id String @id @default(uuid())
167
- projectKey String
168
165
  quotaKey String // 'invoicesPerMonth' | 'members' | 'storageGb' | …
169
166
  label String
170
167
  description String?
@@ -200,8 +197,8 @@ model QuotaCatalogEntry {
200
197
  updatedAt DateTime @updatedAt
201
198
  deletedAt DateTime?
202
199
 
203
- @@unique([projectKey, quotaKey])
204
- @@index([projectKey, discoveryStatus])
200
+ @@unique([quotaKey])
201
+ @@index([discoveryStatus])
205
202
  @@map("quota_catalog_entries")
206
203
  }
207
204
 
@@ -223,8 +220,7 @@ model QuotaCatalogEntry {
223
220
  // -----------------------------------------------------------------------------
224
221
 
225
222
  model MarketingProjection {
226
- id String @id @default(uuid())
227
- projectKey String
223
+ id String @id @default(uuid())
228
224
 
229
225
  // Polymorphic reference to a versioned entity.
230
226
  targetType String // 'PLAN' | 'BUNDLE'
@@ -268,21 +264,25 @@ model MarketingProjection {
268
264
  updatedAt DateTime @updatedAt
269
265
 
270
266
  @@unique([targetType, targetVersionId, locale])
271
- @@index([projectKey, targetType, locale, priority])
267
+ @@index([targetType, locale, priority])
272
268
  @@map("marketing_projections")
273
269
  }
274
270
 
275
271
  // -----------------------------------------------------------------------------
276
- // MarketingSettings — project-wide, runtime-editable marketing config.
277
- // One row per project (`projectKey` unique). Currently exactly one field:
278
- // `activeLocales` the subset of the `availableLocales` pool activated in the
279
- // marketing catalog (the pool comes from the app config, SPEC_V2 §6.5).
280
- // If the row is missing, the full pool counts as active.
272
+ // MarketingSettings — installation-wide, runtime-editable marketing config.
273
+ // At most one row. The constant default gets a caller that omits `id` onto the
274
+ // right row, but it is not what holds the rule a default does not apply to a
275
+ // caller that supplies one, and a primary key accepts every distinct value. The
276
+ // `CHECK` in sql/constraints.postgres.sql is what holds it; the Prisma DSL
277
+ // cannot say it here.
278
+ // Currently exactly one field: `activeLocales` — the subset of the
279
+ // `availableLocales` pool activated in the marketing catalog (the pool comes
280
+ // from the app config, SPEC_V2 §6.5). If the row is missing, the full pool
281
+ // counts as active.
281
282
  // -----------------------------------------------------------------------------
282
283
 
283
284
  model MarketingSettings {
284
- id String @id @default(uuid())
285
- projectKey String @unique
285
+ id String @id @default("marketing-settings")
286
286
 
287
287
  activeLocales Json @default("[]") // string[]
288
288
 
@@ -14,8 +14,7 @@
14
14
  // via the SUPER_ADMIN guard in the AdminController.
15
15
 
16
16
  model Promotion {
17
- id String @id @default(uuid())
18
- projectKey String // e.g. 'clubapp' | 'demoapp' | …
17
+ id String @id @default(uuid())
19
18
 
20
19
  // Internal label (not public).
21
20
  internalLabel String
@@ -60,6 +59,6 @@ model Promotion {
60
59
  createdAt DateTime @default(now())
61
60
  updatedAt DateTime @updatedAt
62
61
 
63
- @@index([projectKey, targetType, validFrom, validTo])
62
+ @@index([targetType, validFrom, validTo])
64
63
  @@map("promotions")
65
64
  }
@@ -25,9 +25,8 @@ enum ContractLineItemKind {
25
25
  }
26
26
 
27
27
  model SubscriptionContract {
28
- id String @id @default(uuid())
29
- projectKey String
30
- tenantId String
28
+ id String @id @default(uuid())
29
+ tenantId String
31
30
 
32
31
  status SubscriptionContractStatus @default(active)
33
32
  effectiveFrom DateTime
@@ -54,7 +53,7 @@ model SubscriptionContract {
54
53
  // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
55
54
 
56
55
  @@index([tenantId, status, effectiveFrom])
57
- @@index([projectKey, status])
56
+ @@index([status])
58
57
  @@index([originalOfferId])
59
58
  @@map("subscription_contracts")
60
59
  }
@@ -29,6 +29,23 @@ model SubscriptionBundle {
29
29
  // only at `minimumTermEndsAt`.
30
30
  minimumTermEndsAt DateTime?
31
31
 
32
+ // The rhythm this booking is billed in, and the window it is billed for.
33
+ //
34
+ // A bundle may run in a shorter rhythm than its plan but never a longer
35
+ // one, so a yearly bundle exists only beside a yearly plan. Its periods end
36
+ // on the day the plan's do: the first one is short, from the booking to the
37
+ // next occurrence of that day, and every one after it runs anchor to
38
+ // anchor. Aligned at booking rather than trimmed at the end, because a trim
39
+ // means somebody was committed to more than they received.
40
+ //
41
+ // Null on a booking made before these columns existed, and on one whose
42
+ // plan has no period at all — a trial, or a subscription awaiting sales.
43
+ // Readers fall back to the plan's cycle, which is what every booking used
44
+ // before this.
45
+ billingCycle String?
46
+ currentPeriodStart DateTime?
47
+ currentPeriodEnd DateTime?
48
+
32
49
  // Cancellation: requested at `canceledAt`, effective at
33
50
  // `canceledEffectiveAt` (period/term end). Both null = active booking.
34
51
  canceledAt DateTime?
@@ -4,22 +4,17 @@
4
4
  "title": "PlanCatalog",
5
5
  "description": "Language-neutral definition of a plan catalog + app identity for a SaaS app. Maintained by the consumer as a YAML/JSON file (`config/saas.yaml`) and mirrored into platform tables at backend boot.",
6
6
  "type": "object",
7
- "required": ["schemaVersion", "projectKey", "currency", "vatRate"],
7
+ "required": ["schemaVersion", "app", "currency", "vatRate"],
8
8
  "additionalProperties": false,
9
9
  "properties": {
10
10
  "schemaVersion": {
11
11
  "type": "integer",
12
12
  "const": 1
13
13
  },
14
- "projectKey": {
15
- "type": "string",
16
- "pattern": "^[a-z][a-z0-9-]{1,30}$",
17
- "description": "Unique key of the consuming project. Cross-audit marker."
18
- },
19
14
  "app": {
20
15
  "type": "object",
21
16
  "additionalProperties": false,
22
- "description": "App identity block (branding + version). Consumed by AdminPublicBootController + AdminManifestConfigFactory.",
17
+ "description": "App identity block (branding + version). One installation serves one application, so this is the only place it is named. Consumed by AdminPublicBootController + AdminManifestConfigFactory.",
23
18
  "required": ["name"],
24
19
  "properties": {
25
20
  "name": {
@@ -0,0 +1,203 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — remove `projectKey` from the catalogue tables.
3
+ -- =============================================================================
4
+ --
5
+ -- project-key-history: this file names the retired identifier because dropping
6
+ -- it is what it does.
7
+ --
8
+ -- One installation serves one application, so a plan key, a bundle key, a
9
+ -- feature key and a quota key are unique for the installation. The column that
10
+ -- suggested otherwise never had a second value to hold: nothing in the platform
11
+ -- could configure a second project, and `subscriptions.tenantId` is unique
12
+ -- installation-wide, so two applications in one database was never a shape this
13
+ -- model could carry.
14
+ --
15
+ -- Run this against an existing database, inside the transaction it opens. It is
16
+ -- a one-way door — the values are dropped, not archived — but it is safe to run
17
+ -- again: a table whose column has already gone is skipped, so a second run does
18
+ -- nothing rather than failing.
19
+ --
20
+ -- psql "$DATABASE_URL" -f 1.0-remove-project-key.postgres.sql
21
+ --
22
+ -- The first statement is a guard. If the affected tables between them hold rows
23
+ -- under more than one project, the migration STOPS and names the table each key
24
+ -- came from, rather than merging rows nobody meant to merge — two `STANDARD`
25
+ -- plans would otherwise collide on the new unique index, and which of them
26
+ -- survives is not a decision a migration should take.
27
+ --
28
+ -- The question is asked across the tables and not once per table. A per-table
29
+ -- count is one where `plans` holds only `alpha` and `bundles` only `beta`, and
30
+ -- that is a two-project installation whose evidence this migration would drop.
31
+ --
32
+ -- Applies to a database created from `reference-schema.postgres.sql` at 0.27 or
33
+ -- any release before 1.0.
34
+
35
+ BEGIN;
36
+
37
+ DO $$
38
+ DECLARE
39
+ affected CONSTANT text[] := ARRAY[
40
+ 'plans',
41
+ 'bundles',
42
+ 'capability_catalog_entries',
43
+ 'feature_catalog_entries',
44
+ 'quota_catalog_entries',
45
+ 'marketing_projections',
46
+ 'marketing_settings',
47
+ 'promotions',
48
+ 'checkout_offers',
49
+ 'subscription_contracts'
50
+ ];
51
+ target text;
52
+ keys_here text[];
53
+ -- Every key seen anywhere, with the table it came from, so the message can
54
+ -- say where the disagreement is rather than only that there is one.
55
+ seen text[] := ARRAY[]::text[];
56
+ witnesses text[] := ARRAY[]::text[];
57
+ BEGIN
58
+ FOREACH target IN ARRAY affected LOOP
59
+ IF to_regclass(format('%I', target)) IS NULL THEN
60
+ CONTINUE; -- an installation that never adopted this fragment
61
+ END IF;
62
+ -- A table that has already been migrated says nothing, and asking it
63
+ -- would raise `column "projectKey" does not exist`. Everything below
64
+ -- this block is written with IF EXISTS / IF NOT EXISTS, so with this
65
+ -- check the whole file is a no-op on a second run rather than an error
66
+ -- — which is what a deploy that retries, or a container that restarts,
67
+ -- needs it to be.
68
+ IF NOT EXISTS (
69
+ SELECT 1 FROM information_schema.columns
70
+ WHERE table_schema = current_schema()
71
+ AND table_name = target
72
+ AND column_name = 'projectKey'
73
+ ) THEN
74
+ CONTINUE;
75
+ END IF;
76
+ EXECUTE format('SELECT array_agg(DISTINCT "projectKey") FROM %I', target)
77
+ INTO keys_here;
78
+ IF keys_here IS NULL THEN
79
+ CONTINUE; -- an empty table says nothing about the installation
80
+ END IF;
81
+ seen := ARRAY(SELECT DISTINCT unnest(seen || keys_here) ORDER BY 1);
82
+ witnesses := witnesses || format('%s: %s', target, array_to_string(keys_here, ', '));
83
+ END LOOP;
84
+
85
+ IF array_length(seen, 1) > 1 THEN
86
+ RAISE EXCEPTION
87
+ 'The catalogue holds rows under % different project keys (%). Removing the column '
88
+ 'would merge them, and a duplicate key would then collide on the new unique index. '
89
+ 'Per table: %. Decide which rows belong to this installation and delete the rest, '
90
+ 'then run this migration again.',
91
+ array_length(seen, 1),
92
+ array_to_string(seen, ', '),
93
+ array_to_string(witnesses, ' | ');
94
+ END IF;
95
+ END $$;
96
+
97
+ -- ─── The indexes that carried the column ───
98
+
99
+ DROP INDEX IF EXISTS "checkout_offers_projectKey_status_idx";
100
+ DROP INDEX IF EXISTS "plans_projectKey_deletedAt_idx";
101
+ DROP INDEX IF EXISTS "plans_projectKey_planKey_key";
102
+ DROP INDEX IF EXISTS "bundles_projectKey_deletedAt_idx";
103
+ DROP INDEX IF EXISTS "bundles_projectKey_bundleKey_key";
104
+ DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_codeStatus_idx";
105
+ DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_featureKey_idx";
106
+ DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_capabilityKey_key";
107
+ DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_discoveryStatus_idx";
108
+ DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_plannedOnly_idx";
109
+ DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_featureKey_key";
110
+ DROP INDEX IF EXISTS "quota_catalog_entries_projectKey_discoveryStatus_idx";
111
+ DROP INDEX IF EXISTS "quota_catalog_entries_projectKey_quotaKey_key";
112
+ DROP INDEX IF EXISTS "marketing_projections_projectKey_targetType_locale_priority_idx";
113
+ DROP INDEX IF EXISTS "marketing_settings_projectKey_key";
114
+ DROP INDEX IF EXISTS "promotions_projectKey_targetType_validFrom_validTo_idx";
115
+ DROP INDEX IF EXISTS "subscription_contracts_projectKey_status_idx";
116
+
117
+ -- ─── The column, the tightened uniques, and the shrunken lookup indexes ───
118
+ --
119
+ -- Per table, and only where the table is there. The Prisma fragments are
120
+ -- adopted à la carte — `saasicat schema check` reports what an app did not take
121
+ -- — and an unconditional `ALTER TABLE "bundles"` raises `relation does not
122
+ -- exist` on an app that never adopted bundles, which rolls back everything,
123
+ -- including the tables it could have migrated. `IF EXISTS` on the column does
124
+ -- not help when it is the relation that is missing.
125
+ --
126
+ -- One list, so a table and what happens to it cannot drift apart.
127
+
128
+ DO $$
129
+ DECLARE
130
+ step record;
131
+ statement text;
132
+ BEGIN
133
+ FOR step IN
134
+ SELECT * FROM (VALUES
135
+ ('plans', ARRAY[
136
+ 'ALTER TABLE "plans" DROP COLUMN IF EXISTS "projectKey"',
137
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "plans_planKey_key" ON "plans"("planKey")',
138
+ 'CREATE INDEX IF NOT EXISTS "plans_deletedAt_idx" ON "plans"("deletedAt")'
139
+ ]),
140
+ ('bundles', ARRAY[
141
+ 'ALTER TABLE "bundles" DROP COLUMN IF EXISTS "projectKey"',
142
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "bundles_bundleKey_key" ON "bundles"("bundleKey")',
143
+ 'CREATE INDEX IF NOT EXISTS "bundles_deletedAt_idx" ON "bundles"("deletedAt")'
144
+ ]),
145
+ ('capability_catalog_entries', ARRAY[
146
+ 'ALTER TABLE "capability_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
147
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "capability_catalog_entries_capabilityKey_key" ON "capability_catalog_entries"("capabilityKey")',
148
+ 'CREATE INDEX IF NOT EXISTS "capability_catalog_entries_codeStatus_idx" ON "capability_catalog_entries"("codeStatus")',
149
+ 'CREATE INDEX IF NOT EXISTS "capability_catalog_entries_featureKey_idx" ON "capability_catalog_entries"("featureKey")'
150
+ ]),
151
+ ('feature_catalog_entries', ARRAY[
152
+ 'ALTER TABLE "feature_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
153
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "feature_catalog_entries_featureKey_key" ON "feature_catalog_entries"("featureKey")',
154
+ 'CREATE INDEX IF NOT EXISTS "feature_catalog_entries_discoveryStatus_idx" ON "feature_catalog_entries"("discoveryStatus")',
155
+ 'CREATE INDEX IF NOT EXISTS "feature_catalog_entries_plannedOnly_idx" ON "feature_catalog_entries"("plannedOnly")'
156
+ ]),
157
+ ('quota_catalog_entries', ARRAY[
158
+ 'ALTER TABLE "quota_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
159
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "quota_catalog_entries_quotaKey_key" ON "quota_catalog_entries"("quotaKey")',
160
+ 'CREATE INDEX IF NOT EXISTS "quota_catalog_entries_discoveryStatus_idx" ON "quota_catalog_entries"("discoveryStatus")'
161
+ ]),
162
+ ('marketing_projections', ARRAY[
163
+ 'ALTER TABLE "marketing_projections" DROP COLUMN IF EXISTS "projectKey"',
164
+ 'CREATE INDEX IF NOT EXISTS "marketing_projections_targetType_locale_priority_idx" ON "marketing_projections"("targetType", "locale", "priority")'
165
+ ]),
166
+ ('promotions', ARRAY[
167
+ 'ALTER TABLE "promotions" DROP COLUMN IF EXISTS "projectKey"',
168
+ 'CREATE INDEX IF NOT EXISTS "promotions_targetType_validFrom_validTo_idx" ON "promotions"("targetType", "validFrom", "validTo")'
169
+ ]),
170
+ ('checkout_offers', ARRAY[
171
+ 'ALTER TABLE "checkout_offers" DROP COLUMN IF EXISTS "projectKey"',
172
+ 'CREATE INDEX IF NOT EXISTS "checkout_offers_status_idx" ON "checkout_offers"("status")'
173
+ ]),
174
+ ('subscription_contracts', ARRAY[
175
+ 'ALTER TABLE "subscription_contracts" DROP COLUMN IF EXISTS "projectKey"',
176
+ 'CREATE INDEX IF NOT EXISTS "subscription_contracts_status_idx" ON "subscription_contracts"("status")'
177
+ ]),
178
+ -- `marketing_settings` loses the column and gains the identity that
179
+ -- replaces it: one row, under a constant primary key. The CHECK is
180
+ -- what actually holds it. A default only applies where the caller
181
+ -- omits the id and a primary key accepts every distinct value, so
182
+ -- without it a consumer writing the row directly could make as many
183
+ -- as it liked — which is what the comment claiming otherwise was
184
+ -- worth.
185
+ ('marketing_settings', ARRAY[
186
+ 'ALTER TABLE "marketing_settings" DROP COLUMN IF EXISTS "projectKey"',
187
+ 'ALTER TABLE "marketing_settings" ALTER COLUMN "id" SET DEFAULT ''marketing-settings''',
188
+ 'UPDATE "marketing_settings" SET "id" = ''marketing-settings''',
189
+ 'ALTER TABLE "marketing_settings" DROP CONSTRAINT IF EXISTS "marketing_settings_is_a_singleton"',
190
+ 'ALTER TABLE "marketing_settings" ADD CONSTRAINT "marketing_settings_is_a_singleton" CHECK ("id" = ''marketing-settings'')'
191
+ ])
192
+ ) AS t(relname, statements)
193
+ LOOP
194
+ IF to_regclass(format('%I', step.relname)) IS NULL THEN
195
+ CONTINUE; -- a fragment this installation never adopted
196
+ END IF;
197
+ FOREACH statement IN ARRAY step.statements LOOP
198
+ EXECUTE statement;
199
+ END LOOP;
200
+ END LOOP;
201
+ END $$;
202
+
203
+ COMMIT;
@@ -17,3 +17,22 @@ CREATE UNIQUE INDEX IF NOT EXISTS plan_versions_draft_per_plan
17
17
 
18
18
  CREATE UNIQUE INDEX IF NOT EXISTS bundle_versions_draft_per_bundle
19
19
  ON bundle_versions ("bundleId") WHERE "publishedAt" IS NULL;
20
+
21
+ -- `marketing_settings` holds at most ONE row.
22
+ --
23
+ -- The Prisma DSL cannot say this, and the constant default on `id` does not: a
24
+ -- default applies only where the caller omits the value, and a primary key
25
+ -- accepts every distinct one. A consumer writing the row directly could make as
26
+ -- many as it liked, and the repository would then read one and ignore the rest.
27
+ --
28
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`, and this file is applied again on
29
+ -- every deployment — so it is dropped first. Two plain statements rather than a
30
+ -- dollar-quoted `DO` block on purpose: every consumer of this file splits it at
31
+ -- the statement separator, and a dollar-quoted body would need each of those
32
+ -- splitters to become a SQL lexer. For the same reason no comment in this file
33
+ -- may contain that separator — one here did, and three splitters cut the
34
+ -- sentence in half.
35
+ ALTER TABLE marketing_settings
36
+ DROP CONSTRAINT IF EXISTS marketing_settings_is_a_singleton;
37
+ ALTER TABLE marketing_settings
38
+ ADD CONSTRAINT marketing_settings_is_a_singleton CHECK ("id" = 'marketing-settings');
@@ -60,8 +60,11 @@ CREATE TABLE "subscriptions" (
60
60
  "trialEndsAt" TIMESTAMP(3),
61
61
  "startedAt" TIMESTAMP(3),
62
62
  "canceledAt" TIMESTAMP(3),
63
+ "canceledEffectiveAt" TIMESTAMP(3),
63
64
  "currentPeriodStart" TIMESTAMP(3),
64
65
  "currentPeriodEnd" TIMESTAMP(3),
66
+ "minimumTermUntil" TIMESTAMP(3),
67
+ "billingAnchorDay" INTEGER,
65
68
  "planVersionId" TEXT NOT NULL,
66
69
  "pendingPlanVersionId" TEXT,
67
70
  "pendingPlanVersionEffectiveAt" TIMESTAMP(3),
@@ -111,7 +114,6 @@ CREATE TABLE "subscription_payment_methods" (
111
114
  -- CreateTable
112
115
  CREATE TABLE "checkout_offers" (
113
116
  "id" TEXT NOT NULL,
114
- "projectKey" TEXT NOT NULL,
115
117
  "planKey" TEXT NOT NULL,
116
118
  "planVersionId" TEXT,
117
119
  "billingCycle" TEXT NOT NULL,
@@ -197,7 +199,6 @@ CREATE TABLE "promo_code_validation_logs" (
197
199
  -- CreateTable
198
200
  CREATE TABLE "plans" (
199
201
  "id" TEXT NOT NULL,
200
- "projectKey" TEXT NOT NULL,
201
202
  "planKey" TEXT NOT NULL,
202
203
  "label" TEXT NOT NULL,
203
204
  "description" TEXT,
@@ -257,7 +258,6 @@ CREATE TABLE "audit_logs" (
257
258
  -- CreateTable
258
259
  CREATE TABLE "bundles" (
259
260
  "id" TEXT NOT NULL,
260
- "projectKey" TEXT NOT NULL,
261
261
  "bundleKey" TEXT NOT NULL,
262
262
  "label" TEXT NOT NULL,
263
263
  "description" TEXT,
@@ -302,7 +302,6 @@ CREATE TABLE "bundle_versions" (
302
302
  -- CreateTable
303
303
  CREATE TABLE "capability_catalog_entries" (
304
304
  "id" TEXT NOT NULL,
305
- "projectKey" TEXT NOT NULL,
306
305
  "capabilityKey" TEXT NOT NULL,
307
306
  "label" TEXT NOT NULL,
308
307
  "description" TEXT,
@@ -327,7 +326,6 @@ CREATE TABLE "capability_catalog_entries" (
327
326
  -- CreateTable
328
327
  CREATE TABLE "feature_catalog_entries" (
329
328
  "id" TEXT NOT NULL,
330
- "projectKey" TEXT NOT NULL,
331
329
  "featureKey" TEXT NOT NULL,
332
330
  "label" TEXT NOT NULL,
333
331
  "description" TEXT,
@@ -356,7 +354,6 @@ CREATE TABLE "feature_catalog_entries" (
356
354
  -- CreateTable
357
355
  CREATE TABLE "quota_catalog_entries" (
358
356
  "id" TEXT NOT NULL,
359
- "projectKey" TEXT NOT NULL,
360
357
  "quotaKey" TEXT NOT NULL,
361
358
  "label" TEXT NOT NULL,
362
359
  "description" TEXT,
@@ -382,7 +379,6 @@ CREATE TABLE "quota_catalog_entries" (
382
379
  -- CreateTable
383
380
  CREATE TABLE "marketing_projections" (
384
381
  "id" TEXT NOT NULL,
385
- "projectKey" TEXT NOT NULL,
386
382
  "targetType" TEXT NOT NULL,
387
383
  "targetVersionId" TEXT NOT NULL,
388
384
  "locale" TEXT NOT NULL DEFAULT 'de',
@@ -405,8 +401,7 @@ CREATE TABLE "marketing_projections" (
405
401
 
406
402
  -- CreateTable
407
403
  CREATE TABLE "marketing_settings" (
408
- "id" TEXT NOT NULL,
409
- "projectKey" TEXT NOT NULL,
404
+ "id" TEXT NOT NULL DEFAULT 'marketing-settings',
410
405
  "activeLocales" JSONB NOT NULL DEFAULT '[]',
411
406
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
412
407
  "updatedAt" TIMESTAMP(3) NOT NULL,
@@ -417,7 +412,6 @@ CREATE TABLE "marketing_settings" (
417
412
  -- CreateTable
418
413
  CREATE TABLE "promotions" (
419
414
  "id" TEXT NOT NULL,
420
- "projectKey" TEXT NOT NULL,
421
415
  "internalLabel" TEXT NOT NULL,
422
416
  "type" TEXT NOT NULL,
423
417
  "value" JSONB NOT NULL,
@@ -441,7 +435,6 @@ CREATE TABLE "promotions" (
441
435
  -- CreateTable
442
436
  CREATE TABLE "subscription_contracts" (
443
437
  "id" TEXT NOT NULL,
444
- "projectKey" TEXT NOT NULL,
445
438
  "tenantId" TEXT NOT NULL,
446
439
  "status" "SubscriptionContractStatus" NOT NULL DEFAULT 'active',
447
440
  "effectiveFrom" TIMESTAMP(3) NOT NULL,
@@ -562,6 +555,9 @@ CREATE TABLE "subscription_bundles" (
562
555
  "bundleVersionId" TEXT NOT NULL,
563
556
  "startedAt" TIMESTAMP(3) NOT NULL,
564
557
  "minimumTermEndsAt" TIMESTAMP(3),
558
+ "billingCycle" TEXT,
559
+ "currentPeriodStart" TIMESTAMP(3),
560
+ "currentPeriodEnd" TIMESTAMP(3),
565
561
  "canceledAt" TIMESTAMP(3),
566
562
  "canceledEffectiveAt" TIMESTAMP(3),
567
563
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -589,7 +585,7 @@ CREATE INDEX "subscriptions_currentPeriodEnd_idx" ON "subscriptions"("currentPer
589
585
  CREATE UNIQUE INDEX "subscription_payment_methods_subscriptionId_key" ON "subscription_payment_methods"("subscriptionId");
590
586
 
591
587
  -- CreateIndex
592
- CREATE INDEX "checkout_offers_projectKey_status_idx" ON "checkout_offers"("projectKey", "status");
588
+ CREATE INDEX "checkout_offers_status_idx" ON "checkout_offers"("status");
593
589
 
594
590
  -- CreateIndex
595
591
  CREATE UNIQUE INDEX "promo_codes_code_key" ON "promo_codes"("code");
@@ -622,10 +618,10 @@ CREATE INDEX "promo_code_validation_logs_ipHash_createdAt_idx" ON "promo_code_va
622
618
  CREATE INDEX "promo_code_validation_logs_sessionId_createdAt_idx" ON "promo_code_validation_logs"("sessionId", "createdAt");
623
619
 
624
620
  -- CreateIndex
625
- CREATE INDEX "plans_projectKey_deletedAt_idx" ON "plans"("projectKey", "deletedAt");
621
+ CREATE INDEX "plans_deletedAt_idx" ON "plans"("deletedAt");
626
622
 
627
623
  -- CreateIndex
628
- CREATE UNIQUE INDEX "plans_projectKey_planKey_key" ON "plans"("projectKey", "planKey");
624
+ CREATE UNIQUE INDEX "plans_planKey_key" ON "plans"("planKey");
629
625
 
630
626
  -- CreateIndex
631
627
  CREATE INDEX "plan_versions_planId_supersededAt_idx" ON "plan_versions"("planId", "supersededAt");
@@ -652,10 +648,10 @@ CREATE INDEX "audit_logs_entity_action_createdAt_idx" ON "audit_logs"("entity",
652
648
  CREATE INDEX "audit_logs_actorTag_createdAt_idx" ON "audit_logs"("actorTag", "createdAt");
653
649
 
654
650
  -- CreateIndex
655
- CREATE INDEX "bundles_projectKey_deletedAt_idx" ON "bundles"("projectKey", "deletedAt");
651
+ CREATE INDEX "bundles_deletedAt_idx" ON "bundles"("deletedAt");
656
652
 
657
653
  -- CreateIndex
658
- CREATE UNIQUE INDEX "bundles_projectKey_bundleKey_key" ON "bundles"("projectKey", "bundleKey");
654
+ CREATE UNIQUE INDEX "bundles_bundleKey_key" ON "bundles"("bundleKey");
659
655
 
660
656
  -- CreateIndex
661
657
  CREATE INDEX "bundle_versions_bundleId_supersededAt_idx" ON "bundle_versions"("bundleId", "supersededAt");
@@ -670,46 +666,43 @@ CREATE INDEX "bundle_versions_bundleId_validFrom_idx" ON "bundle_versions"("bund
670
666
  CREATE UNIQUE INDEX "bundle_versions_bundleId_version_key" ON "bundle_versions"("bundleId", "version");
671
667
 
672
668
  -- CreateIndex
673
- CREATE INDEX "capability_catalog_entries_projectKey_codeStatus_idx" ON "capability_catalog_entries"("projectKey", "codeStatus");
669
+ CREATE INDEX "capability_catalog_entries_codeStatus_idx" ON "capability_catalog_entries"("codeStatus");
674
670
 
675
671
  -- CreateIndex
676
- CREATE INDEX "capability_catalog_entries_projectKey_featureKey_idx" ON "capability_catalog_entries"("projectKey", "featureKey");
672
+ CREATE INDEX "capability_catalog_entries_featureKey_idx" ON "capability_catalog_entries"("featureKey");
677
673
 
678
674
  -- CreateIndex
679
- CREATE UNIQUE INDEX "capability_catalog_entries_projectKey_capabilityKey_key" ON "capability_catalog_entries"("projectKey", "capabilityKey");
675
+ CREATE UNIQUE INDEX "capability_catalog_entries_capabilityKey_key" ON "capability_catalog_entries"("capabilityKey");
680
676
 
681
677
  -- CreateIndex
682
- CREATE INDEX "feature_catalog_entries_projectKey_discoveryStatus_idx" ON "feature_catalog_entries"("projectKey", "discoveryStatus");
678
+ CREATE INDEX "feature_catalog_entries_discoveryStatus_idx" ON "feature_catalog_entries"("discoveryStatus");
683
679
 
684
680
  -- CreateIndex
685
- CREATE INDEX "feature_catalog_entries_projectKey_plannedOnly_idx" ON "feature_catalog_entries"("projectKey", "plannedOnly");
681
+ CREATE INDEX "feature_catalog_entries_plannedOnly_idx" ON "feature_catalog_entries"("plannedOnly");
686
682
 
687
683
  -- CreateIndex
688
- CREATE UNIQUE INDEX "feature_catalog_entries_projectKey_featureKey_key" ON "feature_catalog_entries"("projectKey", "featureKey");
684
+ CREATE UNIQUE INDEX "feature_catalog_entries_featureKey_key" ON "feature_catalog_entries"("featureKey");
689
685
 
690
686
  -- CreateIndex
691
- CREATE INDEX "quota_catalog_entries_projectKey_discoveryStatus_idx" ON "quota_catalog_entries"("projectKey", "discoveryStatus");
687
+ CREATE INDEX "quota_catalog_entries_discoveryStatus_idx" ON "quota_catalog_entries"("discoveryStatus");
692
688
 
693
689
  -- CreateIndex
694
- CREATE UNIQUE INDEX "quota_catalog_entries_projectKey_quotaKey_key" ON "quota_catalog_entries"("projectKey", "quotaKey");
690
+ CREATE UNIQUE INDEX "quota_catalog_entries_quotaKey_key" ON "quota_catalog_entries"("quotaKey");
695
691
 
696
692
  -- CreateIndex
697
- CREATE INDEX "marketing_projections_projectKey_targetType_locale_priority_idx" ON "marketing_projections"("projectKey", "targetType", "locale", "priority");
693
+ CREATE INDEX "marketing_projections_targetType_locale_priority_idx" ON "marketing_projections"("targetType", "locale", "priority");
698
694
 
699
695
  -- CreateIndex
700
696
  CREATE UNIQUE INDEX "marketing_projections_targetType_targetVersionId_locale_key" ON "marketing_projections"("targetType", "targetVersionId", "locale");
701
697
 
702
698
  -- CreateIndex
703
- CREATE UNIQUE INDEX "marketing_settings_projectKey_key" ON "marketing_settings"("projectKey");
704
-
705
- -- CreateIndex
706
- CREATE INDEX "promotions_projectKey_targetType_validFrom_validTo_idx" ON "promotions"("projectKey", "targetType", "validFrom", "validTo");
699
+ CREATE INDEX "promotions_targetType_validFrom_validTo_idx" ON "promotions"("targetType", "validFrom", "validTo");
707
700
 
708
701
  -- CreateIndex
709
702
  CREATE INDEX "subscription_contracts_tenantId_status_effectiveFrom_idx" ON "subscription_contracts"("tenantId", "status", "effectiveFrom");
710
703
 
711
704
  -- CreateIndex
712
- CREATE INDEX "subscription_contracts_projectKey_status_idx" ON "subscription_contracts"("projectKey", "status");
705
+ CREATE INDEX "subscription_contracts_status_idx" ON "subscription_contracts"("status");
713
706
 
714
707
  -- CreateIndex
715
708
  CREATE INDEX "subscription_contracts_originalOfferId_idx" ON "subscription_contracts"("originalOfferId");
@@ -808,3 +801,22 @@ CREATE UNIQUE INDEX IF NOT EXISTS plan_versions_draft_per_plan
808
801
 
809
802
  CREATE UNIQUE INDEX IF NOT EXISTS bundle_versions_draft_per_bundle
810
803
  ON bundle_versions ("bundleId") WHERE "publishedAt" IS NULL;
804
+
805
+ -- `marketing_settings` holds at most ONE row.
806
+ --
807
+ -- The Prisma DSL cannot say this, and the constant default on `id` does not: a
808
+ -- default applies only where the caller omits the value, and a primary key
809
+ -- accepts every distinct one. A consumer writing the row directly could make as
810
+ -- many as it liked, and the repository would then read one and ignore the rest.
811
+ --
812
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`, and this file is applied again on
813
+ -- every deployment — so it is dropped first. Two plain statements rather than a
814
+ -- dollar-quoted `DO` block on purpose: every consumer of this file splits it at
815
+ -- the statement separator, and a dollar-quoted body would need each of those
816
+ -- splitters to become a SQL lexer. For the same reason no comment in this file
817
+ -- may contain that separator — one here did, and three splitters cut the
818
+ -- sentence in half.
819
+ ALTER TABLE marketing_settings
820
+ DROP CONSTRAINT IF EXISTS marketing_settings_is_a_singleton;
821
+ ALTER TABLE marketing_settings
822
+ ADD CONSTRAINT marketing_settings_is_a_singleton CHECK ("id" = 'marketing-settings');