@saasicat/spec 1.0.0-rc.14 → 1.0.0-rc.16

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.
@@ -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.14
32
+ version: 1.0.0-rc.16
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.14",
3
+ "version": "1.0.0-rc.16",
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",
@@ -8,6 +8,12 @@
8
8
  // V3 rule: billing and entitlement must not depend on live-mutable catalog
9
9
  // tables. `SubscriptionContract` and `ContractLineItem` therefore store full
10
10
  // snapshots. Catalog FKs are optional and only audit/trace references.
11
+ //
12
+ // A contract belongs to its subscriber (13-subscriber.prisma), not to the
13
+ // tenant, and outlives the tenant (ADR 0012). `tenantId` is kept as a trace of
14
+ // the tenant it was concluded for, and deliberately has no relation to the
15
+ // application's `Tenant` model: a cascade there deletes the tax record with the
16
+ // tenant, and a restriction keeps a tenant from ever being deleted.
11
17
 
12
18
  enum SubscriptionContractStatus {
13
19
  active
@@ -28,6 +34,19 @@ model SubscriptionContract {
28
34
  id String @id @default(uuid())
29
35
  tenantId String
30
36
 
37
+ // Who the contract is between, copied on the day it is concluded: the
38
+ // subscriber as its record stood, and the issuer as `config/saas.yaml`
39
+ // named it — null where it named none. Neither copy follows a later change
40
+ // to the subscriber or the configuration. The invoice email is not copied:
41
+ // it says how the party is reached, not who it is.
42
+ subscriberId String
43
+ subscriberSnapshot Json
44
+ issuerSnapshot Json?
45
+ // The copies were made by the migration that attached contracts concluded
46
+ // before subscribers existed, not at conclusion, and are never presented as
47
+ // what was agreed.
48
+ partiesMigrated Boolean @default(false)
49
+
31
50
  status SubscriptionContractStatus @default(active)
32
51
  effectiveFrom DateTime
33
52
  effectiveUntil DateTime?
@@ -50,9 +69,10 @@ model SubscriptionContract {
50
69
  createdAt DateTime @default(now())
51
70
  updatedAt DateTime @updatedAt
52
71
 
53
- // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
72
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
54
73
 
55
74
  @@index([tenantId, status, effectiveFrom])
75
+ @@index([subscriberId])
56
76
  @@index([status])
57
77
  @@index([originalOfferId])
58
78
  @@map("subscription_contracts")
@@ -0,0 +1,92 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: Subscriber, its tenants, its corrections
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // A tenant is where the application keeps a customer's data. The subscriber is
8
+ // the party the contract is with: a customer number and the master data a
9
+ // contract names. A tenant can be deleted; its subscriber stays for as long as
10
+ // a document that has to be kept belongs to it (ADR 0012). That is why nothing
11
+ // here points at the application's `Tenant` model with a cascade — the link
12
+ // below names the tenant as a value and stays after the tenant row is gone.
13
+ //
14
+ // Every model carries `Subscriber` in its name, so it cannot collide with a
15
+ // `Customer` an application already keeps for the people it sells to.
16
+
17
+ model Subscriber {
18
+ id String @id @default(uuid())
19
+
20
+ // The customer number, counted per installation. The number orders; the
21
+ // prefix is the one `config/saas.yaml` named when the subscriber was
22
+ // created, kept beside it so a later prefix renames nobody. Counting starts
23
+ // at 10001 (sql/constraints.postgres.sql), so numbers have five digits long
24
+ // before they need a sixth.
25
+ customerSequence Int @unique @default(autoincrement())
26
+ customerNumberPrefix String @default("")
27
+
28
+ // The legal identity. Only the name is required until invoicing requires
29
+ // the rest; under a running contract these change only as a correction,
30
+ // recorded in `SubscriberCorrection`.
31
+ legalName String
32
+ vatId String?
33
+ taxNumber String?
34
+
35
+ // Contact details, which change at any time.
36
+ addressLine1 String?
37
+ addressLine2 String?
38
+ postalCode String?
39
+ city String?
40
+ country String? // ISO 3166-1 alpha-2
41
+ invoiceEmail String?
42
+
43
+ // Created by the migration that gave every existing tenant its subscriber,
44
+ // from the application's own tenant record.
45
+ migrated Boolean @default(false)
46
+
47
+ createdAt DateTime @default(now())
48
+ updatedAt DateTime @updatedAt
49
+
50
+ tenants SubscriberTenant[]
51
+ corrections SubscriberCorrection[]
52
+ contracts SubscriptionContract[]
53
+
54
+ @@map("subscribers")
55
+ }
56
+
57
+ // Which tenant a subscriber is live for, and which it was before. A subscriber
58
+ // has at most one live tenant and a tenant at most one live subscriber — two
59
+ // partial unique indexes in sql/constraints.postgres.sql, which Prisma cannot
60
+ // express. `unlinkedAt` stays null while the link is live.
61
+ model SubscriberTenant {
62
+ id String @id @default(uuid())
63
+ subscriberId String
64
+ tenantId String
65
+ linkedAt DateTime @default(now())
66
+ unlinkedAt DateTime?
67
+
68
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
69
+
70
+ @@index([tenantId])
71
+ @@index([subscriberId])
72
+ @@map("subscriber_tenants")
73
+ }
74
+
75
+ // A correction of a subscriber's legal identity: the values it replaced, the
76
+ // values it wrote, why, and who made it. Only the fields that moved are in the
77
+ // two JSON objects. Written in the same transaction as the change, and never
78
+ // rewritten.
79
+ model SubscriberCorrection {
80
+ id String @id @default(uuid())
81
+ subscriberId String
82
+ previous Json
83
+ corrected Json
84
+ reason String
85
+ correctedBy String
86
+ correctedAt DateTime
87
+
88
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
89
+
90
+ @@index([subscriberId, correctedAt])
91
+ @@map("subscriber_corrections")
92
+ }
@@ -42,6 +42,7 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
42
42
  | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
43
  | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
44
44
  | [`12-applied-settings.prisma`](12-applied-settings.prisma) | `AppliedSettings`, `SettingsChange` |
45
+ | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection` |
45
46
 
46
47
  ## How the consumer uses the fragments
47
48
 
@@ -83,6 +84,12 @@ Fields such as `tenantId String` and `userId String?` remain as plain
83
84
  string columns in the fragments; the corresponding `@relation` is left as a
84
85
  comment. The consumer enables them using their own `Tenant`/`User` model names.
85
86
 
87
+ Two models deliberately carry no such pointer: `SubscriptionContract` and
88
+ `SubscriberTenant`. A contract belongs to its subscriber and outlives the tenant
89
+ it was concluded for, so its `tenantId` is a trace — a cascade from the tenant
90
+ would delete the tax record with it, and a restriction would keep the tenant
91
+ from ever being deleted.
92
+
86
93
  ### 3. Table names (`@@map`) are canonical
87
94
 
88
95
  `subscriptions`, `subscription_payment_methods`, `checkout_offers`, `plans`,
@@ -92,7 +99,8 @@ comment. The consumer enables them using their own `Tenant`/`User` model names.
92
99
  `quota_catalog_entries`, `marketing_projections`, `marketing_settings`,
93
100
  `promotions`, `subscription_contracts`, `contract_line_items`,
94
101
  `pending_registrations`, `payment_event_logs`, `super_admin_users`,
95
- `super_admin_mfa`, `subscription_bundles`.
102
+ `super_admin_mfa`, `subscription_bundles`, `subscribers`, `subscriber_tenants`,
103
+ `subscriber_corrections`.
96
104
  Please do **not change** them — otherwise platform migration scripts and the
97
105
  `@saasicat/cli` commands that rely on these names will break.
98
106
 
@@ -125,5 +133,12 @@ CREATE UNIQUE INDEX plan_versions_draft_per_plan
125
133
  - **No add-on tables (#49)** — `subscription_addons`,
126
134
  `unit_addon_versions`, `feature_addon_versions` are not a
127
135
  sales surface; only plan versions + bundles are sold.
128
- - **App-specific tables** (e.g. invoice or bank master data)
129
- belong in the schema of the consuming app, not in the platform.
136
+ - **The application's own invoicing** invoices to the people it sells to, fees
137
+ it collects from its members, their bank details belongs in the schema of the
138
+ consuming app. The subscription business is the platform's: subscribers, their
139
+ invoices and their payments are decided in
140
+ [ADR 0012](../../../docs/explanation/adr/0012-the-subscriber-owns-the-commercial-record.md),
141
+ and every model added for them carries `Subscription` or `Subscriber` in its name,
142
+ which keeps it clear of the names applications use for their own invoicing. A
143
+ prefix cannot rule out a name nobody has seen, so `saasicat schema check` reports
144
+ an application model that carries a platform model's name with a different shape.
@@ -137,6 +137,64 @@
137
137
  }
138
138
  }
139
139
  },
140
+ "issuer": {
141
+ "type": "object",
142
+ "additionalProperties": false,
143
+ "required": ["legalName"],
144
+ "description": "The legal entity on the operator's side of every contract this installation concludes, and later of every invoice it issues. A contract copies it on the day it is concluded, so the contract keeps naming its counterparty after this block changes. Optional for now: a contract concluded while it is absent records that no issuer was named.",
145
+ "properties": {
146
+ "legalName": {
147
+ "type": "string",
148
+ "minLength": 1,
149
+ "description": "The registered name, legal form included, as a contract names the party (e.g. \"Example Software GmbH\")."
150
+ },
151
+ "addressLine1": {
152
+ "type": "string",
153
+ "minLength": 1,
154
+ "description": "Street and number."
155
+ },
156
+ "addressLine2": {
157
+ "type": "string",
158
+ "minLength": 1,
159
+ "description": "A second address line, such as a building or a c/o."
160
+ },
161
+ "postalCode": {
162
+ "type": "string",
163
+ "minLength": 1
164
+ },
165
+ "city": {
166
+ "type": "string",
167
+ "minLength": 1
168
+ },
169
+ "country": {
170
+ "type": "string",
171
+ "pattern": "^[A-Z]{2}$",
172
+ "description": "ISO 3166-1 alpha-2 country code, e.g. DE."
173
+ },
174
+ "vatId": {
175
+ "type": "string",
176
+ "minLength": 1,
177
+ "description": "VAT identification number, e.g. DE123456789."
178
+ },
179
+ "taxNumber": {
180
+ "type": "string",
181
+ "minLength": 1,
182
+ "description": "The tax number the issuer's tax office assigned, where it is stated beside or instead of the VAT identification number."
183
+ }
184
+ }
185
+ },
186
+ "subscribers": {
187
+ "type": "object",
188
+ "additionalProperties": false,
189
+ "description": "How the parties this installation concludes contracts with are numbered.",
190
+ "properties": {
191
+ "customerNumberPrefix": {
192
+ "type": "string",
193
+ "pattern": "^[A-Za-z0-9._/-]{1,16}$",
194
+ "description": "Put in front of every customer number assigned from the next start on: `K-` gives K-10001. A customer number keeps the prefix it was assigned with, so changing this renumbers nobody. Omitted, a customer number is the number alone."
195
+ }
196
+ }
197
+ },
140
198
  "features": {
141
199
  "type": "array",
142
200
  "items": {
@@ -0,0 +1,383 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — every contract names the subscriber it is concluded with.
3
+ -- =============================================================================
4
+ --
5
+ -- A tenant is where the application keeps a customer's data; the subscriber is
6
+ -- the party the contract is with (ADR 0012). This file brings an installation
7
+ -- that has contracts to that model. Run it BEFORE `db push`, like the other
8
+ -- files in this directory:
9
+ --
10
+ -- psql "$DATABASE_URL" -f 1.0-a-contract-names-its-subscriber.postgres.sql
11
+ --
12
+ -- What it does, in two transactions:
13
+ --
14
+ -- 1. Creates `subscribers`, `subscriber_tenants` and `subscriber_corrections`
15
+ -- and adds `subscriberId`, `subscriberSnapshot`, `issuerSnapshot` and
16
+ -- `partiesMigrated` to `subscription_contracts`, nullable, exactly as the
17
+ -- shipped fragments declare them otherwise. Nothing depends on these yet,
18
+ -- so they stay when the second transaction stops.
19
+ --
20
+ -- The second transaction does the rest, or nothing:
21
+ --
22
+ -- 2. Gives every tenant that has a subscription or a contract and no live
23
+ -- subscriber one of its own, marked `migrated`, numbered from 10001 in the
24
+ -- order the tenants first appear.
25
+ -- 3. Attaches every contract to its tenant's subscriber and copies the
26
+ -- subscriber onto it, with `partiesMigrated` set: the copy is made now,
27
+ -- not when the contract was concluded, and is never presented as what was
28
+ -- agreed. No issuer is copied — this file cannot read `config/saas.yaml`.
29
+ -- 4. Makes `subscriberId` and `subscriberSnapshot` required.
30
+ --
31
+ -- Where the legal name comes from. SaaSiCat keeps no master data of its own
32
+ -- about a tenant; the application does, in its own table. This file finds that
33
+ -- table through the foreign key the application declared on
34
+ -- `subscriptions."tenantId"` — or, where there is none, on
35
+ -- `subscription_contracts."tenantId"` — and takes the tenant's `name` column as
36
+ -- the subscriber's legal name. It stops, naming what it found, when there is no
37
+ -- such foreign key, when that table has no `name` column, or when a tenant has
38
+ -- no row there or an empty name: a subscriber named after an identifier is
39
+ -- worse than a migration that did not run. The tables from step 1 are in place
40
+ -- by then, and `docs/guides/upgrade-to-1.0.md` shows the statement that creates
41
+ -- those subscribers by hand before this file runs again.
42
+ --
43
+ -- The prefix. A customer number keeps the prefix it was assigned with, and this
44
+ -- file cannot read `subscribers.customerNumberPrefix` from `config/saas.yaml`
45
+ -- either. Set it for the session to number the migrated subscribers the way the
46
+ -- installation numbers new ones:
47
+ --
48
+ -- psql "$DATABASE_URL" -c "SET saasicat.customer_number_prefix = 'K-'" \
49
+ -- -f 1.0-a-contract-names-its-subscriber.postgres.sql
50
+ --
51
+ -- Without it they carry the number alone. A value the configuration would
52
+ -- refuse stops the migration.
53
+ --
54
+ -- Row-level security. Where `subscription_contracts`, `subscriptions` or the
55
+ -- tenant table has it and this role would see only some of their rows, the file
56
+ -- stops before it creates a subscriber: run it as a role that bypasses
57
+ -- row-level security.
58
+ --
59
+ -- Safe to run again: every object is created only where it is missing, and once
60
+ -- the link is required the second transaction returns before it reads a row, so
61
+ -- a later run creates nothing and row-level security cannot stop it. The first
62
+ -- transaction still needs a role that owns the tables, as `db push` does. A
63
+ -- tenant an application creates after that without a subscriber is the
64
+ -- application's to give one; this file does not paper over it. The numbering is moved only while it has never handed
65
+ -- out a number. On a database created from `reference-schema.postgres.sql` the
66
+ -- whole file does nothing at all.
67
+
68
+ BEGIN;
69
+
70
+ -- 1. The tables and columns, as the fragments declare them ----------------------
71
+
72
+ DO $$
73
+ BEGIN
74
+ IF to_regclass('subscription_contracts') IS NULL THEN
75
+ RAISE NOTICE 'subscription_contracts is not present — nothing to migrate.';
76
+ RETURN; -- an installation that never adopted the contract fragment
77
+ END IF;
78
+
79
+ CREATE TABLE IF NOT EXISTS "subscribers" (
80
+ "id" TEXT NOT NULL,
81
+ "customerSequence" SERIAL NOT NULL,
82
+ "customerNumberPrefix" TEXT NOT NULL DEFAULT '',
83
+ "legalName" TEXT NOT NULL,
84
+ "vatId" TEXT,
85
+ "taxNumber" TEXT,
86
+ "addressLine1" TEXT,
87
+ "addressLine2" TEXT,
88
+ "postalCode" TEXT,
89
+ "city" TEXT,
90
+ "country" TEXT,
91
+ "invoiceEmail" TEXT,
92
+ "migrated" BOOLEAN NOT NULL DEFAULT false,
93
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
94
+ "updatedAt" TIMESTAMP(3) NOT NULL,
95
+ CONSTRAINT "subscribers_pkey" PRIMARY KEY ("id")
96
+ );
97
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscribers_customerSequence_key"
98
+ ON "subscribers"("customerSequence");
99
+
100
+ CREATE TABLE IF NOT EXISTS "subscriber_tenants" (
101
+ "id" TEXT NOT NULL,
102
+ "subscriberId" TEXT NOT NULL,
103
+ "tenantId" TEXT NOT NULL,
104
+ "linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
105
+ "unlinkedAt" TIMESTAMP(3),
106
+ CONSTRAINT "subscriber_tenants_pkey" PRIMARY KEY ("id")
107
+ );
108
+ CREATE INDEX IF NOT EXISTS "subscriber_tenants_tenantId_idx"
109
+ ON "subscriber_tenants"("tenantId");
110
+ CREATE INDEX IF NOT EXISTS "subscriber_tenants_subscriberId_idx"
111
+ ON "subscriber_tenants"("subscriberId");
112
+ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_tenant
113
+ ON subscriber_tenants ("tenantId") WHERE "unlinkedAt" IS NULL;
114
+ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_subscriber
115
+ ON subscriber_tenants ("subscriberId") WHERE "unlinkedAt" IS NULL;
116
+
117
+ CREATE TABLE IF NOT EXISTS "subscriber_corrections" (
118
+ "id" TEXT NOT NULL,
119
+ "subscriberId" TEXT NOT NULL,
120
+ "previous" JSONB NOT NULL,
121
+ "corrected" JSONB NOT NULL,
122
+ "reason" TEXT NOT NULL,
123
+ "correctedBy" TEXT NOT NULL,
124
+ "correctedAt" TIMESTAMP(3) NOT NULL,
125
+ CONSTRAINT "subscriber_corrections_pkey" PRIMARY KEY ("id")
126
+ );
127
+ CREATE INDEX IF NOT EXISTS "subscriber_corrections_subscriberId_correctedAt_idx"
128
+ ON "subscriber_corrections"("subscriberId", "correctedAt");
129
+
130
+ ALTER TABLE "subscription_contracts" ADD COLUMN IF NOT EXISTS "subscriberId" TEXT;
131
+ ALTER TABLE "subscription_contracts" ADD COLUMN IF NOT EXISTS "subscriberSnapshot" JSONB;
132
+ ALTER TABLE "subscription_contracts" ADD COLUMN IF NOT EXISTS "issuerSnapshot" JSONB;
133
+ ALTER TABLE "subscription_contracts"
134
+ ADD COLUMN IF NOT EXISTS "partiesMigrated" BOOLEAN NOT NULL DEFAULT false;
135
+ CREATE INDEX IF NOT EXISTS "subscription_contracts_subscriberId_idx"
136
+ ON "subscription_contracts"("subscriberId");
137
+
138
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`, so each foreign key is added only
139
+ -- where its name is not taken yet.
140
+ IF NOT EXISTS (
141
+ SELECT 1 FROM pg_constraint
142
+ WHERE conrelid = to_regclass('subscriber_tenants')
143
+ AND conname = 'subscriber_tenants_subscriberId_fkey'
144
+ ) THEN
145
+ ALTER TABLE "subscriber_tenants"
146
+ ADD CONSTRAINT "subscriber_tenants_subscriberId_fkey"
147
+ FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
148
+ ON DELETE RESTRICT ON UPDATE CASCADE;
149
+ END IF;
150
+ IF NOT EXISTS (
151
+ SELECT 1 FROM pg_constraint
152
+ WHERE conrelid = to_regclass('subscriber_corrections')
153
+ AND conname = 'subscriber_corrections_subscriberId_fkey'
154
+ ) THEN
155
+ ALTER TABLE "subscriber_corrections"
156
+ ADD CONSTRAINT "subscriber_corrections_subscriberId_fkey"
157
+ FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
158
+ ON DELETE RESTRICT ON UPDATE CASCADE;
159
+ END IF;
160
+ IF NOT EXISTS (
161
+ SELECT 1 FROM pg_constraint
162
+ WHERE conrelid = to_regclass('subscription_contracts')
163
+ AND conname = 'subscription_contracts_subscriberId_fkey'
164
+ ) THEN
165
+ ALTER TABLE "subscription_contracts"
166
+ ADD CONSTRAINT "subscription_contracts_subscriberId_fkey"
167
+ FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
168
+ ON DELETE RESTRICT ON UPDATE CASCADE;
169
+ END IF;
170
+
171
+ -- The numbering starts at 10001, as `constraints.postgres.sql` has it, and
172
+ -- is moved only while it has never handed out a number.
173
+ ALTER SEQUENCE IF EXISTS "subscribers_customerSequence_seq" START WITH 10001;
174
+ PERFORM setval(format('%I.%I', schemaname, sequencename)::regclass, 10001, false)
175
+ FROM pg_sequences
176
+ WHERE schemaname = current_schema()
177
+ AND sequencename = 'subscribers_customerSequence_seq'
178
+ AND last_value IS NULL;
179
+ END $$;
180
+
181
+ COMMIT;
182
+
183
+ BEGIN;
184
+
185
+ DO $$
186
+ DECLARE
187
+ prefix text := coalesce(current_setting('saasicat.customer_number_prefix', true), '');
188
+ hidden text[];
189
+ tenant_table regclass;
190
+ tenant_key text;
191
+ unnamed text[];
192
+ tenant record;
193
+ new_subscriber text;
194
+ BEGIN
195
+ IF to_regclass('subscription_contracts') IS NULL THEN
196
+ RETURN; -- said once already, by the first transaction
197
+ END IF;
198
+
199
+ -- Run through already: the link is required, and nothing below is left to
200
+ -- do. Asked of the catalogue rather than of the rows, so row-level security
201
+ -- cannot change the answer, and an entrypoint that applies this file again
202
+ -- as the application's own role is not stopped by the guard below. From
203
+ -- here on, a tenant without a subscriber is the application's to give one.
204
+ IF (SELECT attnotnull FROM pg_attribute
205
+ WHERE attrelid = to_regclass('subscription_contracts')
206
+ AND attname = 'subscriberId'
207
+ AND attnum > 0
208
+ AND NOT attisdropped) THEN
209
+ RETURN;
210
+ END IF;
211
+
212
+ -- The application's tenant table: the one a single-column foreign key on
213
+ -- "tenantId" points at, from the subscriptions first. Read off the
214
+ -- catalogue, before any row is.
215
+ SELECT c.confrelid::regclass, referenced.attname
216
+ INTO tenant_table, tenant_key
217
+ FROM pg_constraint c
218
+ JOIN pg_attribute referencing
219
+ ON referencing.attrelid = c.conrelid AND referencing.attnum = c.conkey[1]
220
+ JOIN pg_attribute referenced
221
+ ON referenced.attrelid = c.confrelid AND referenced.attnum = c.confkey[1]
222
+ WHERE c.contype = 'f'
223
+ AND cardinality(c.conkey) = 1
224
+ AND referencing.attname = 'tenantId'
225
+ AND c.conrelid IN (to_regclass('subscriptions'), to_regclass('subscription_contracts'))
226
+ ORDER BY c.conrelid = to_regclass('subscriptions') DESC, c.conname
227
+ LIMIT 1;
228
+
229
+ -- A table under row-level security shows this role only the rows its
230
+ -- policies allow. Hidden contracts or subscriptions would leave tenants
231
+ -- without a subscriber and fail the required link with a message about
232
+ -- null values; hidden rows of the tenant table would be reported as tenants
233
+ -- without a name. Either way the message would name the wrong cause.
234
+ SELECT array_agg(c.relname::text ORDER BY c.relname)
235
+ INTO hidden
236
+ FROM pg_class c
237
+ WHERE c.oid IN (
238
+ to_regclass('subscription_contracts'), to_regclass('subscriptions'), tenant_table
239
+ )
240
+ AND c.relrowsecurity
241
+ AND (c.relforcerowsecurity OR NOT pg_has_role(current_user, c.relowner, 'MEMBER'))
242
+ AND NOT EXISTS (
243
+ SELECT 1 FROM pg_roles r
244
+ WHERE r.rolname = current_user AND (r.rolsuper OR r.rolbypassrls)
245
+ );
246
+ IF hidden IS NOT NULL THEN
247
+ RAISE EXCEPTION
248
+ 'Cannot see every row of % under row-level security as role %, so subscribers would '
249
+ 'be missing or unnamed for the tenants it hides. Run this file as a role that '
250
+ 'bypasses row-level security. No subscriber was created.',
251
+ array_to_string(hidden, ', '),
252
+ current_user;
253
+ END IF;
254
+
255
+ IF prefix <> '' AND prefix !~ '^[A-Za-z0-9._/-]{1,16}$' THEN
256
+ RAISE EXCEPTION
257
+ 'saasicat.customer_number_prefix is %, which config/saas.yaml would refuse: a prefix '
258
+ 'is 1 to 16 letters, digits, dots, slashes, hyphens or underscores. No subscriber '
259
+ 'was created.',
260
+ quote_literal(prefix);
261
+ END IF;
262
+
263
+ -- 2. A subscriber for every tenant that has none ---------------------------
264
+
265
+ -- The tenants in question, each with the moment it first appears, so the
266
+ -- numbers follow the order the customers came in. Worked out once, because
267
+ -- the refusal and the creation below have to agree about who is meant.
268
+ CREATE TEMP TABLE _saasicat_tenants ON COMMIT DROP AS
269
+ SELECT seen."tenantId" AS tenant_id, min(seen.at) AS first_seen, NULL::text AS legal_name
270
+ FROM (
271
+ SELECT "tenantId", "createdAt" AS at FROM "subscription_contracts"
272
+ ) seen
273
+ GROUP BY seen."tenantId";
274
+ IF to_regclass('subscriptions') IS NOT NULL THEN
275
+ INSERT INTO _saasicat_tenants (tenant_id, first_seen)
276
+ SELECT s."tenantId", s."createdAt"
277
+ FROM "subscriptions" s
278
+ WHERE NOT EXISTS (SELECT 1 FROM _saasicat_tenants t WHERE t.tenant_id = s."tenantId");
279
+ UPDATE _saasicat_tenants t
280
+ SET first_seen = least(t.first_seen, s."createdAt")
281
+ FROM "subscriptions" s
282
+ WHERE s."tenantId" = t.tenant_id;
283
+ END IF;
284
+ DELETE FROM _saasicat_tenants t
285
+ WHERE EXISTS (
286
+ SELECT 1 FROM "subscriber_tenants" l
287
+ WHERE l."tenantId" = t.tenant_id AND l."unlinkedAt" IS NULL
288
+ );
289
+
290
+ IF EXISTS (SELECT 1 FROM _saasicat_tenants) THEN
291
+ IF tenant_table IS NULL THEN
292
+ RAISE EXCEPTION
293
+ 'Cannot create the subscribers of % tenant(s) (%): no foreign key on '
294
+ 'subscriptions."tenantId" or subscription_contracts."tenantId" names the '
295
+ 'application''s tenant table, so there is nowhere to read a legal name from. '
296
+ 'The tables are in place: create their subscribers as docs/guides/upgrade-to-1.0.md '
297
+ 'shows and run this file again. No subscriber was created.',
298
+ (SELECT count(*) FROM _saasicat_tenants),
299
+ (SELECT array_to_string((array_agg(tenant_id ORDER BY tenant_id))[1:10], ', ')
300
+ FROM _saasicat_tenants);
301
+ END IF;
302
+
303
+ IF NOT EXISTS (
304
+ SELECT 1 FROM pg_attribute
305
+ WHERE attrelid = tenant_table
306
+ AND attname = 'name'
307
+ AND attnum > 0
308
+ AND NOT attisdropped
309
+ ) THEN
310
+ RAISE EXCEPTION
311
+ 'Cannot create the subscribers of % tenant(s): their table % has no "name" column '
312
+ 'to take a legal name from. The tables are in place: create their subscribers as '
313
+ 'docs/guides/upgrade-to-1.0.md shows and run this file again. No subscriber was '
314
+ 'created.',
315
+ (SELECT count(*) FROM _saasicat_tenants),
316
+ tenant_table;
317
+ END IF;
318
+
319
+ EXECUTE format(
320
+ 'UPDATE _saasicat_tenants t SET legal_name = nullif(btrim(a."name"::text), '''') '
321
+ 'FROM %s a WHERE a.%I::text = t.tenant_id',
322
+ tenant_table, tenant_key
323
+ );
324
+
325
+ SELECT array_agg(tenant_id ORDER BY tenant_id)
326
+ INTO unnamed
327
+ FROM _saasicat_tenants
328
+ WHERE legal_name IS NULL;
329
+ IF unnamed IS NOT NULL THEN
330
+ RAISE EXCEPTION
331
+ 'Cannot create the subscribers of % tenant(s) (%): % has no row for them, or an '
332
+ 'empty name, and a subscriber is not named after an identifier. The tables are in '
333
+ 'place: create their subscribers as docs/guides/upgrade-to-1.0.md shows and run '
334
+ 'this file again. No subscriber was created.',
335
+ array_length(unnamed, 1),
336
+ array_to_string(unnamed[1:10], ', ')
337
+ || CASE WHEN array_length(unnamed, 1) > 10 THEN ', …' ELSE '' END,
338
+ tenant_table;
339
+ END IF;
340
+
341
+ -- One at a time, in the order the tenants came in: the numbers are drawn
342
+ -- in exactly that order, which a set-based insert does not promise.
343
+ FOR tenant IN
344
+ SELECT tenant_id, legal_name FROM _saasicat_tenants ORDER BY first_seen, tenant_id
345
+ LOOP
346
+ new_subscriber := gen_random_uuid()::text;
347
+ INSERT INTO "subscribers"
348
+ ("id", "customerNumberPrefix", "legalName", "migrated", "updatedAt")
349
+ VALUES (new_subscriber, prefix, tenant.legal_name, true, CURRENT_TIMESTAMP);
350
+ INSERT INTO "subscriber_tenants" ("id", "subscriberId", "tenantId")
351
+ VALUES (gen_random_uuid()::text, new_subscriber, tenant.tenant_id);
352
+ END LOOP;
353
+ END IF;
354
+
355
+ -- 3. Every contract names its tenant's subscriber -------------------------
356
+
357
+ UPDATE "subscription_contracts" c
358
+ SET "subscriberId" = s."id",
359
+ "subscriberSnapshot" = jsonb_build_object(
360
+ 'customerNumber', s."customerNumberPrefix" || s."customerSequence",
361
+ 'legalName', s."legalName",
362
+ 'vatId', s."vatId",
363
+ 'taxNumber', s."taxNumber",
364
+ 'addressLine1', s."addressLine1",
365
+ 'addressLine2', s."addressLine2",
366
+ 'postalCode', s."postalCode",
367
+ 'city', s."city",
368
+ 'country', s."country"
369
+ ),
370
+ "partiesMigrated" = true
371
+ FROM "subscriber_tenants" l
372
+ JOIN "subscribers" s ON s."id" = l."subscriberId"
373
+ WHERE l."tenantId" = c."tenantId"
374
+ AND l."unlinkedAt" IS NULL
375
+ AND c."subscriberId" IS NULL;
376
+
377
+ -- 4. Required from here on -------------------------------------------------
378
+
379
+ ALTER TABLE "subscription_contracts" ALTER COLUMN "subscriberId" SET NOT NULL;
380
+ ALTER TABLE "subscription_contracts" ALTER COLUMN "subscriberSnapshot" SET NOT NULL;
381
+ END $$;
382
+
383
+ COMMIT;
@@ -46,3 +46,27 @@ ALTER TABLE applied_settings
46
46
  DROP CONSTRAINT IF EXISTS applied_settings_is_a_singleton;
47
47
  ALTER TABLE applied_settings
48
48
  ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
49
+
50
+ -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
51
+ -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
52
+ -- tenants a subscriber had before stay in its history. Two partial unique
53
+ -- indexes, because a link that is over must not count against the next one.
54
+ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_tenant
55
+ ON subscriber_tenants ("tenantId") WHERE "unlinkedAt" IS NULL;
56
+
57
+ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_subscriber
58
+ ON subscriber_tenants ("subscriberId") WHERE "unlinkedAt" IS NULL;
59
+
60
+ -- Customer numbers count from 10001, so a number has five digits up to 99999
61
+ -- and none reads as a count of subscribers. Two statements: the first makes
62
+ -- 10001 where the sequence starts over, which a restart of its identity reads,
63
+ -- and the second moves a sequence that has never handed out a number there. A
64
+ -- second run finds the start already set and the sequence used, and an
65
+ -- installation without the subscriber tables has no such sequence.
66
+ ALTER SEQUENCE IF EXISTS "subscribers_customerSequence_seq" START WITH 10001;
67
+
68
+ SELECT setval(format('%I.%I', schemaname, sequencename)::regclass, 10001, false)
69
+ FROM pg_sequences
70
+ WHERE schemaname = current_schema()
71
+ AND sequencename = 'subscribers_customerSequence_seq'
72
+ AND last_value IS NULL;
@@ -15,6 +15,7 @@
15
15
  -- prisma-fragments/10-super-admin.prisma
16
16
  -- prisma-fragments/11-subscription-bundle.prisma
17
17
  -- prisma-fragments/12-applied-settings.prisma
18
+ -- prisma-fragments/13-subscriber.prisma
18
19
  -- plus the normative constraints from sql/constraints.postgres.sql.
19
20
  -- Do not edit by hand — change the fragments/constraints and regenerate.
20
21
 
@@ -437,6 +438,10 @@ CREATE TABLE "promotions" (
437
438
  CREATE TABLE "subscription_contracts" (
438
439
  "id" TEXT NOT NULL,
439
440
  "tenantId" TEXT NOT NULL,
441
+ "subscriberId" TEXT NOT NULL,
442
+ "subscriberSnapshot" JSONB NOT NULL,
443
+ "issuerSnapshot" JSONB,
444
+ "partiesMigrated" BOOLEAN NOT NULL DEFAULT false,
440
445
  "status" "SubscriptionContractStatus" NOT NULL DEFAULT 'active',
441
446
  "effectiveFrom" TIMESTAMP(3) NOT NULL,
442
447
  "effectiveUntil" TIMESTAMP(3),
@@ -595,6 +600,51 @@ CREATE TABLE "settings_changes" (
595
600
  CONSTRAINT "settings_changes_pkey" PRIMARY KEY ("id")
596
601
  );
597
602
 
603
+ -- CreateTable
604
+ CREATE TABLE "subscribers" (
605
+ "id" TEXT NOT NULL,
606
+ "customerSequence" SERIAL NOT NULL,
607
+ "customerNumberPrefix" TEXT NOT NULL DEFAULT '',
608
+ "legalName" TEXT NOT NULL,
609
+ "vatId" TEXT,
610
+ "taxNumber" TEXT,
611
+ "addressLine1" TEXT,
612
+ "addressLine2" TEXT,
613
+ "postalCode" TEXT,
614
+ "city" TEXT,
615
+ "country" TEXT,
616
+ "invoiceEmail" TEXT,
617
+ "migrated" BOOLEAN NOT NULL DEFAULT false,
618
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
619
+ "updatedAt" TIMESTAMP(3) NOT NULL,
620
+
621
+ CONSTRAINT "subscribers_pkey" PRIMARY KEY ("id")
622
+ );
623
+
624
+ -- CreateTable
625
+ CREATE TABLE "subscriber_tenants" (
626
+ "id" TEXT NOT NULL,
627
+ "subscriberId" TEXT NOT NULL,
628
+ "tenantId" TEXT NOT NULL,
629
+ "linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
630
+ "unlinkedAt" TIMESTAMP(3),
631
+
632
+ CONSTRAINT "subscriber_tenants_pkey" PRIMARY KEY ("id")
633
+ );
634
+
635
+ -- CreateTable
636
+ CREATE TABLE "subscriber_corrections" (
637
+ "id" TEXT NOT NULL,
638
+ "subscriberId" TEXT NOT NULL,
639
+ "previous" JSONB NOT NULL,
640
+ "corrected" JSONB NOT NULL,
641
+ "reason" TEXT NOT NULL,
642
+ "correctedBy" TEXT NOT NULL,
643
+ "correctedAt" TIMESTAMP(3) NOT NULL,
644
+
645
+ CONSTRAINT "subscriber_corrections_pkey" PRIMARY KEY ("id")
646
+ );
647
+
598
648
  -- CreateIndex
599
649
  CREATE UNIQUE INDEX "subscriptions_tenantId_key" ON "subscriptions"("tenantId");
600
650
 
@@ -730,6 +780,9 @@ CREATE INDEX "promotions_targetType_validFrom_validTo_idx" ON "promotions"("targ
730
780
  -- CreateIndex
731
781
  CREATE INDEX "subscription_contracts_tenantId_status_effectiveFrom_idx" ON "subscription_contracts"("tenantId", "status", "effectiveFrom");
732
782
 
783
+ -- CreateIndex
784
+ CREATE INDEX "subscription_contracts_subscriberId_idx" ON "subscription_contracts"("subscriberId");
785
+
733
786
  -- CreateIndex
734
787
  CREATE INDEX "subscription_contracts_status_idx" ON "subscription_contracts"("status");
735
788
 
@@ -781,6 +834,18 @@ CREATE UNIQUE INDEX "settings_changes_seq_key" ON "settings_changes"("seq");
781
834
  -- CreateIndex
782
835
  CREATE INDEX "settings_changes_acknowledgedAt_noticedAt_idx" ON "settings_changes"("acknowledgedAt", "noticedAt");
783
836
 
837
+ -- CreateIndex
838
+ CREATE UNIQUE INDEX "subscribers_customerSequence_key" ON "subscribers"("customerSequence");
839
+
840
+ -- CreateIndex
841
+ CREATE INDEX "subscriber_tenants_tenantId_idx" ON "subscriber_tenants"("tenantId");
842
+
843
+ -- CreateIndex
844
+ CREATE INDEX "subscriber_tenants_subscriberId_idx" ON "subscriber_tenants"("subscriberId");
845
+
846
+ -- CreateIndex
847
+ CREATE INDEX "subscriber_corrections_subscriberId_correctedAt_idx" ON "subscriber_corrections"("subscriberId", "correctedAt");
848
+
784
849
  -- AddForeignKey
785
850
  ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planVersionId_fkey" FOREIGN KEY ("planVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
786
851
 
@@ -808,6 +873,9 @@ ALTER TABLE "bundle_versions" ADD CONSTRAINT "bundle_versions_bundleId_fkey" FOR
808
873
  -- AddForeignKey
809
874
  ALTER TABLE "bundle_versions" ADD CONSTRAINT "bundle_versions_baseVersionId_fkey" FOREIGN KEY ("baseVersionId") REFERENCES "bundle_versions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
810
875
 
876
+ -- AddForeignKey
877
+ ALTER TABLE "subscription_contracts" ADD CONSTRAINT "subscription_contracts_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
878
+
811
879
  -- AddForeignKey
812
880
  ALTER TABLE "contract_line_items" ADD CONSTRAINT "contract_line_items_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "subscription_contracts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
813
881
 
@@ -817,6 +885,12 @@ ALTER TABLE "subscription_bundles" ADD CONSTRAINT "subscription_bundles_subscrip
817
885
  -- AddForeignKey
818
886
  ALTER TABLE "subscription_bundles" ADD CONSTRAINT "subscription_bundles_bundleVersionId_fkey" FOREIGN KEY ("bundleVersionId") REFERENCES "bundle_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
819
887
 
888
+ -- AddForeignKey
889
+ ALTER TABLE "subscriber_tenants" ADD CONSTRAINT "subscriber_tenants_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
890
+
891
+ -- AddForeignKey
892
+ ALTER TABLE "subscriber_corrections" ADD CONSTRAINT "subscriber_corrections_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
893
+
820
894
  -- =============================================================================
821
895
  -- SaaSiCat — normative PostgreSQL constraints the Prisma DSL cannot express.
822
896
  -- =============================================================================
@@ -865,3 +939,27 @@ ALTER TABLE applied_settings
865
939
  DROP CONSTRAINT IF EXISTS applied_settings_is_a_singleton;
866
940
  ALTER TABLE applied_settings
867
941
  ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
942
+
943
+ -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
944
+ -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
945
+ -- tenants a subscriber had before stay in its history. Two partial unique
946
+ -- indexes, because a link that is over must not count against the next one.
947
+ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_tenant
948
+ ON subscriber_tenants ("tenantId") WHERE "unlinkedAt" IS NULL;
949
+
950
+ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_subscriber
951
+ ON subscriber_tenants ("subscriberId") WHERE "unlinkedAt" IS NULL;
952
+
953
+ -- Customer numbers count from 10001, so a number has five digits up to 99999
954
+ -- and none reads as a count of subscribers. Two statements: the first makes
955
+ -- 10001 where the sequence starts over, which a restart of its identity reads,
956
+ -- and the second moves a sequence that has never handed out a number there. A
957
+ -- second run finds the start already set and the sequence used, and an
958
+ -- installation without the subscriber tables has no such sequence.
959
+ ALTER SEQUENCE IF EXISTS "subscribers_customerSequence_seq" START WITH 10001;
960
+
961
+ SELECT setval(format('%I.%I', schemaname, sequencename)::regclass, 10001, false)
962
+ FROM pg_sequences
963
+ WHERE schemaname = current_schema()
964
+ AND sequencename = 'subscribers_customerSequence_seq'
965
+ AND last_value IS NULL;