@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.
- package/admin-api.openapi.yaml +1 -1
- package/package.json +1 -1
- package/prisma-fragments/08-subscription-contract.prisma +21 -1
- package/prisma-fragments/13-subscriber.prisma +92 -0
- package/prisma-fragments/README.md +18 -3
- package/schemas/plan-catalog.schema.json +58 -0
- package/sql/1.0-a-contract-names-its-subscriber.postgres.sql +383 -0
- package/sql/constraints.postgres.sql +24 -0
- package/sql/reference-schema.postgres.sql +98 -0
package/admin-api.openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
- **
|
|
129
|
-
|
|
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;
|