@saasicat/spec 1.0.0-rc.6 → 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.6
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.6",
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
  }
@@ -84,6 +84,19 @@ model Subscription {
84
84
  // field. Readers treat null as "the period end is the answer".
85
85
  minimumTermUntil DateTime?
86
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
+
87
100
  // Plan versioning — binds to a published PlanVersion.
88
101
  // `pendingPlanVersion*` concern a version change within the
89
102
  // same plan; `pendingPlan` the change to a different plan. Both
@@ -188,8 +201,7 @@ model SubscriptionPaymentMethod {
188
201
  // -----------------------------------------------------------------------------
189
202
 
190
203
  model CheckoutOffer {
191
- id String @id @default(uuid())
192
- projectKey String
204
+ id String @id @default(uuid())
193
205
 
194
206
  planKey String
195
207
  planVersionId String?
@@ -214,6 +226,6 @@ model CheckoutOffer {
214
226
  createdAt DateTime @default(now())
215
227
  updatedAt DateTime @updatedAt
216
228
 
217
- @@index([projectKey, status])
229
+ @@index([status])
218
230
  @@map("checkout_offers")
219
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');
@@ -64,6 +64,7 @@ CREATE TABLE "subscriptions" (
64
64
  "currentPeriodStart" TIMESTAMP(3),
65
65
  "currentPeriodEnd" TIMESTAMP(3),
66
66
  "minimumTermUntil" TIMESTAMP(3),
67
+ "billingAnchorDay" INTEGER,
67
68
  "planVersionId" TEXT NOT NULL,
68
69
  "pendingPlanVersionId" TEXT,
69
70
  "pendingPlanVersionEffectiveAt" TIMESTAMP(3),
@@ -113,7 +114,6 @@ CREATE TABLE "subscription_payment_methods" (
113
114
  -- CreateTable
114
115
  CREATE TABLE "checkout_offers" (
115
116
  "id" TEXT NOT NULL,
116
- "projectKey" TEXT NOT NULL,
117
117
  "planKey" TEXT NOT NULL,
118
118
  "planVersionId" TEXT,
119
119
  "billingCycle" TEXT NOT NULL,
@@ -199,7 +199,6 @@ CREATE TABLE "promo_code_validation_logs" (
199
199
  -- CreateTable
200
200
  CREATE TABLE "plans" (
201
201
  "id" TEXT NOT NULL,
202
- "projectKey" TEXT NOT NULL,
203
202
  "planKey" TEXT NOT NULL,
204
203
  "label" TEXT NOT NULL,
205
204
  "description" TEXT,
@@ -259,7 +258,6 @@ CREATE TABLE "audit_logs" (
259
258
  -- CreateTable
260
259
  CREATE TABLE "bundles" (
261
260
  "id" TEXT NOT NULL,
262
- "projectKey" TEXT NOT NULL,
263
261
  "bundleKey" TEXT NOT NULL,
264
262
  "label" TEXT NOT NULL,
265
263
  "description" TEXT,
@@ -304,7 +302,6 @@ CREATE TABLE "bundle_versions" (
304
302
  -- CreateTable
305
303
  CREATE TABLE "capability_catalog_entries" (
306
304
  "id" TEXT NOT NULL,
307
- "projectKey" TEXT NOT NULL,
308
305
  "capabilityKey" TEXT NOT NULL,
309
306
  "label" TEXT NOT NULL,
310
307
  "description" TEXT,
@@ -329,7 +326,6 @@ CREATE TABLE "capability_catalog_entries" (
329
326
  -- CreateTable
330
327
  CREATE TABLE "feature_catalog_entries" (
331
328
  "id" TEXT NOT NULL,
332
- "projectKey" TEXT NOT NULL,
333
329
  "featureKey" TEXT NOT NULL,
334
330
  "label" TEXT NOT NULL,
335
331
  "description" TEXT,
@@ -358,7 +354,6 @@ CREATE TABLE "feature_catalog_entries" (
358
354
  -- CreateTable
359
355
  CREATE TABLE "quota_catalog_entries" (
360
356
  "id" TEXT NOT NULL,
361
- "projectKey" TEXT NOT NULL,
362
357
  "quotaKey" TEXT NOT NULL,
363
358
  "label" TEXT NOT NULL,
364
359
  "description" TEXT,
@@ -384,7 +379,6 @@ CREATE TABLE "quota_catalog_entries" (
384
379
  -- CreateTable
385
380
  CREATE TABLE "marketing_projections" (
386
381
  "id" TEXT NOT NULL,
387
- "projectKey" TEXT NOT NULL,
388
382
  "targetType" TEXT NOT NULL,
389
383
  "targetVersionId" TEXT NOT NULL,
390
384
  "locale" TEXT NOT NULL DEFAULT 'de',
@@ -407,8 +401,7 @@ CREATE TABLE "marketing_projections" (
407
401
 
408
402
  -- CreateTable
409
403
  CREATE TABLE "marketing_settings" (
410
- "id" TEXT NOT NULL,
411
- "projectKey" TEXT NOT NULL,
404
+ "id" TEXT NOT NULL DEFAULT 'marketing-settings',
412
405
  "activeLocales" JSONB NOT NULL DEFAULT '[]',
413
406
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
414
407
  "updatedAt" TIMESTAMP(3) NOT NULL,
@@ -419,7 +412,6 @@ CREATE TABLE "marketing_settings" (
419
412
  -- CreateTable
420
413
  CREATE TABLE "promotions" (
421
414
  "id" TEXT NOT NULL,
422
- "projectKey" TEXT NOT NULL,
423
415
  "internalLabel" TEXT NOT NULL,
424
416
  "type" TEXT NOT NULL,
425
417
  "value" JSONB NOT NULL,
@@ -443,7 +435,6 @@ CREATE TABLE "promotions" (
443
435
  -- CreateTable
444
436
  CREATE TABLE "subscription_contracts" (
445
437
  "id" TEXT NOT NULL,
446
- "projectKey" TEXT NOT NULL,
447
438
  "tenantId" TEXT NOT NULL,
448
439
  "status" "SubscriptionContractStatus" NOT NULL DEFAULT 'active',
449
440
  "effectiveFrom" TIMESTAMP(3) NOT NULL,
@@ -564,6 +555,9 @@ CREATE TABLE "subscription_bundles" (
564
555
  "bundleVersionId" TEXT NOT NULL,
565
556
  "startedAt" TIMESTAMP(3) NOT NULL,
566
557
  "minimumTermEndsAt" TIMESTAMP(3),
558
+ "billingCycle" TEXT,
559
+ "currentPeriodStart" TIMESTAMP(3),
560
+ "currentPeriodEnd" TIMESTAMP(3),
567
561
  "canceledAt" TIMESTAMP(3),
568
562
  "canceledEffectiveAt" TIMESTAMP(3),
569
563
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -591,7 +585,7 @@ CREATE INDEX "subscriptions_currentPeriodEnd_idx" ON "subscriptions"("currentPer
591
585
  CREATE UNIQUE INDEX "subscription_payment_methods_subscriptionId_key" ON "subscription_payment_methods"("subscriptionId");
592
586
 
593
587
  -- CreateIndex
594
- CREATE INDEX "checkout_offers_projectKey_status_idx" ON "checkout_offers"("projectKey", "status");
588
+ CREATE INDEX "checkout_offers_status_idx" ON "checkout_offers"("status");
595
589
 
596
590
  -- CreateIndex
597
591
  CREATE UNIQUE INDEX "promo_codes_code_key" ON "promo_codes"("code");
@@ -624,10 +618,10 @@ CREATE INDEX "promo_code_validation_logs_ipHash_createdAt_idx" ON "promo_code_va
624
618
  CREATE INDEX "promo_code_validation_logs_sessionId_createdAt_idx" ON "promo_code_validation_logs"("sessionId", "createdAt");
625
619
 
626
620
  -- CreateIndex
627
- CREATE INDEX "plans_projectKey_deletedAt_idx" ON "plans"("projectKey", "deletedAt");
621
+ CREATE INDEX "plans_deletedAt_idx" ON "plans"("deletedAt");
628
622
 
629
623
  -- CreateIndex
630
- CREATE UNIQUE INDEX "plans_projectKey_planKey_key" ON "plans"("projectKey", "planKey");
624
+ CREATE UNIQUE INDEX "plans_planKey_key" ON "plans"("planKey");
631
625
 
632
626
  -- CreateIndex
633
627
  CREATE INDEX "plan_versions_planId_supersededAt_idx" ON "plan_versions"("planId", "supersededAt");
@@ -654,10 +648,10 @@ CREATE INDEX "audit_logs_entity_action_createdAt_idx" ON "audit_logs"("entity",
654
648
  CREATE INDEX "audit_logs_actorTag_createdAt_idx" ON "audit_logs"("actorTag", "createdAt");
655
649
 
656
650
  -- CreateIndex
657
- CREATE INDEX "bundles_projectKey_deletedAt_idx" ON "bundles"("projectKey", "deletedAt");
651
+ CREATE INDEX "bundles_deletedAt_idx" ON "bundles"("deletedAt");
658
652
 
659
653
  -- CreateIndex
660
- CREATE UNIQUE INDEX "bundles_projectKey_bundleKey_key" ON "bundles"("projectKey", "bundleKey");
654
+ CREATE UNIQUE INDEX "bundles_bundleKey_key" ON "bundles"("bundleKey");
661
655
 
662
656
  -- CreateIndex
663
657
  CREATE INDEX "bundle_versions_bundleId_supersededAt_idx" ON "bundle_versions"("bundleId", "supersededAt");
@@ -672,46 +666,43 @@ CREATE INDEX "bundle_versions_bundleId_validFrom_idx" ON "bundle_versions"("bund
672
666
  CREATE UNIQUE INDEX "bundle_versions_bundleId_version_key" ON "bundle_versions"("bundleId", "version");
673
667
 
674
668
  -- CreateIndex
675
- 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");
676
670
 
677
671
  -- CreateIndex
678
- 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");
679
673
 
680
674
  -- CreateIndex
681
- 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");
682
676
 
683
677
  -- CreateIndex
684
- 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");
685
679
 
686
680
  -- CreateIndex
687
- 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");
688
682
 
689
683
  -- CreateIndex
690
- 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");
691
685
 
692
686
  -- CreateIndex
693
- 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");
694
688
 
695
689
  -- CreateIndex
696
- 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");
697
691
 
698
692
  -- CreateIndex
699
- 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");
700
694
 
701
695
  -- CreateIndex
702
696
  CREATE UNIQUE INDEX "marketing_projections_targetType_targetVersionId_locale_key" ON "marketing_projections"("targetType", "targetVersionId", "locale");
703
697
 
704
698
  -- CreateIndex
705
- CREATE UNIQUE INDEX "marketing_settings_projectKey_key" ON "marketing_settings"("projectKey");
706
-
707
- -- CreateIndex
708
- 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");
709
700
 
710
701
  -- CreateIndex
711
702
  CREATE INDEX "subscription_contracts_tenantId_status_effectiveFrom_idx" ON "subscription_contracts"("tenantId", "status", "effectiveFrom");
712
703
 
713
704
  -- CreateIndex
714
- CREATE INDEX "subscription_contracts_projectKey_status_idx" ON "subscription_contracts"("projectKey", "status");
705
+ CREATE INDEX "subscription_contracts_status_idx" ON "subscription_contracts"("status");
715
706
 
716
707
  -- CreateIndex
717
708
  CREATE INDEX "subscription_contracts_originalOfferId_idx" ON "subscription_contracts"("originalOfferId");
@@ -810,3 +801,22 @@ CREATE UNIQUE INDEX IF NOT EXISTS plan_versions_draft_per_plan
810
801
 
811
802
  CREATE UNIQUE INDEX IF NOT EXISTS bundle_versions_draft_per_bundle
812
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');