@saasicat/spec 1.0.0-rc.2 → 1.0.0-rc.21
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 +30 -0
- package/acceptance/README.md +3 -3
- package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +6 -6
- package/admin-api.openapi.yaml +331 -77
- package/cli-conventions.md +19 -19
- package/index.cjs +3 -0
- package/index.d.cts +3 -1
- package/index.d.ts +2 -0
- package/index.js +9 -1
- package/package.json +4 -2
- package/prisma-fragments/01-subscription.prisma +34 -47
- package/prisma-fragments/02-promo-code.prisma +38 -0
- package/prisma-fragments/03-plan-versions.prisma +3 -4
- package/prisma-fragments/05-bundle.prisma +4 -5
- package/prisma-fragments/06-catalog-entries.prisma +24 -24
- package/prisma-fragments/07-promotion.prisma +2 -3
- package/prisma-fragments/08-subscription-contract.prisma +42 -5
- package/prisma-fragments/09-pending-registration.prisma +32 -27
- package/prisma-fragments/11-subscription-bundle.prisma +17 -0
- package/prisma-fragments/12-applied-settings.prisma +55 -0
- package/prisma-fragments/13-subscriber.prisma +94 -0
- package/prisma-fragments/14-payments.prisma +108 -0
- package/prisma-fragments/README.md +40 -22
- package/schemas/admin-manifest.schema.json +10 -3
- package/schemas/plan-catalog.schema.json +239 -18
- package/schemas/promo-code.schema.json +7 -1
- package/schemas/tenant-ledger.schema.json +236 -0
- package/sql/1.0-a-contract-names-its-subscriber.postgres.sql +383 -0
- package/sql/1.0-a-payment-method-is-a-gateway-reference.postgres.sql +177 -0
- package/sql/1.0-a-promo-slot-is-held-through-checkout.postgres.sql +69 -0
- package/sql/1.0-a-settings-change-carries-its-order.postgres.sql +73 -0
- package/sql/1.0-line-items-record-their-money.postgres.sql +134 -0
- package/sql/1.0-remove-project-key.postgres.sql +203 -0
- package/sql/1.0-the-applied-settings-are-recorded.postgres.sql +67 -0
- package/sql/constraints.postgres.sql +76 -0
- package/sql/reference-schema.postgres.sql +324 -77
|
@@ -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;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- SaaSiCat 1.0 — a payment method is the gateway's reference, kept for the subscriber.
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
--
|
|
5
|
+
-- SaaSiCat takes payment methods through a payment gateway: the person enters
|
|
6
|
+
-- the card or the IBAN in the gateway's own form, and SaaSiCat keeps the
|
|
7
|
+
-- gateway's reference with the masked details, for the subscriber (ADR 0012).
|
|
8
|
+
-- Run it BEFORE `db push`, and after
|
|
9
|
+
-- `1.0-a-contract-names-its-subscriber.postgres.sql`, whose `subscribers` table
|
|
10
|
+
-- it points at:
|
|
11
|
+
--
|
|
12
|
+
-- psql "$DATABASE_URL" -f 1.0-a-payment-method-is-a-gateway-reference.postgres.sql
|
|
13
|
+
--
|
|
14
|
+
-- What it touches, in one transaction, each part only where its table is there:
|
|
15
|
+
--
|
|
16
|
+
-- 1. `subscriber_payment_methods` — created with its indexes, its foreign key
|
|
17
|
+
-- to `subscribers` and the partial unique index that allows one payment
|
|
18
|
+
-- method in use per subscriber — and `subscriber_payment_method_setups`,
|
|
19
|
+
-- the changes of payment method a tenant started. Skipped where
|
|
20
|
+
-- `subscribers` is missing.
|
|
21
|
+
-- 2. `"PaymentEventLog"` — gains `gatewayAccount`. An event identifier is
|
|
22
|
+
-- unique only within the gateway account that sent it, so the unique index
|
|
23
|
+
-- on `eventId` alone is replaced by one on both. An event recorded before
|
|
24
|
+
-- this file carries no account; it is given its `provider` as one, which
|
|
25
|
+
-- keeps the old identifiers unique and matches no account a gateway sends
|
|
26
|
+
-- from now on. A second index holds one confirmation per gateway session
|
|
27
|
+
-- (`sql/constraints.postgres.sql` says what it is for). It matches no row
|
|
28
|
+
-- recorded before this file: `status` then held `PaymentEventStatus`,
|
|
29
|
+
-- `SUCCEEDED` or `FAILED`, never the event kinds SaaSiCat reads today. A
|
|
30
|
+
-- row that does carry one is refused by name rather than by a unique
|
|
31
|
+
-- violation, which only a database run against an unreleased build of
|
|
32
|
+
-- this change can hold.
|
|
33
|
+
-- 3. `"PendingRegistration"` — gains the billing details step 4 asks for,
|
|
34
|
+
-- `checkoutGatewayAccount` and `gatewayCustomerRef`. A sign-up whose
|
|
35
|
+
-- checkout started before this file has no account beside its session, so
|
|
36
|
+
-- no confirmation can find it; the person repeats step 4.
|
|
37
|
+
--
|
|
38
|
+
-- What it leaves alone: `subscription_payment_methods` and the enum
|
|
39
|
+
-- `SubscriptionPaymentType`. The fragments no longer declare them, and nothing
|
|
40
|
+
-- in SaaSiCat reads them, but an application may still write there and the rows
|
|
41
|
+
-- are its own. Drop both once nothing does — `docs/guides/upgrade-to-1.0.md`
|
|
42
|
+
-- has the statements. `db push` offers to drop them as well.
|
|
43
|
+
--
|
|
44
|
+
-- Safe to run again: every table, column and index is created only where it is
|
|
45
|
+
-- missing, the foreign key only where its name is not taken, the event log's
|
|
46
|
+
-- account is filled only where it is empty, and the old unique index is dropped
|
|
47
|
+
-- only where it still exists. On a database created from
|
|
48
|
+
-- `reference-schema.postgres.sql` the whole file does nothing at all.
|
|
49
|
+
|
|
50
|
+
BEGIN;
|
|
51
|
+
|
|
52
|
+
DO $$
|
|
53
|
+
DECLARE
|
|
54
|
+
twice RECORD;
|
|
55
|
+
BEGIN
|
|
56
|
+
-- 1. The payment methods, for the subscriber ------------------------------
|
|
57
|
+
|
|
58
|
+
IF to_regclass('subscribers') IS NULL THEN
|
|
59
|
+
RAISE NOTICE 'subscribers is not present — subscriber_payment_methods is not created; run 1.0-a-contract-names-its-subscriber.postgres.sql first where contracts are kept.';
|
|
60
|
+
ELSE
|
|
61
|
+
CREATE TABLE IF NOT EXISTS "subscriber_payment_methods" (
|
|
62
|
+
"id" TEXT NOT NULL,
|
|
63
|
+
"subscriberId" TEXT NOT NULL,
|
|
64
|
+
"gatewayAccount" TEXT NOT NULL,
|
|
65
|
+
"provider" TEXT NOT NULL,
|
|
66
|
+
"customerRef" TEXT NOT NULL,
|
|
67
|
+
"paymentMethodRef" TEXT NOT NULL,
|
|
68
|
+
"type" TEXT NOT NULL,
|
|
69
|
+
"brand" TEXT,
|
|
70
|
+
"last4" TEXT NOT NULL,
|
|
71
|
+
"expiryMonth" INTEGER,
|
|
72
|
+
"expiryYear" INTEGER,
|
|
73
|
+
"country" TEXT,
|
|
74
|
+
"bankCode" TEXT,
|
|
75
|
+
"mandateReference" TEXT,
|
|
76
|
+
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
|
77
|
+
"confirmedAt" TIMESTAMP(3) NOT NULL,
|
|
78
|
+
"replacedAt" TIMESTAMP(3),
|
|
79
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
80
|
+
CONSTRAINT "subscriber_payment_methods_pkey" PRIMARY KEY ("id")
|
|
81
|
+
);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS "subscriber_payment_methods_subscriberId_status_idx"
|
|
83
|
+
ON "subscriber_payment_methods"("subscriberId", "status");
|
|
84
|
+
CREATE INDEX IF NOT EXISTS "subscriber_payment_methods_gatewayAccount_status_idx"
|
|
85
|
+
ON "subscriber_payment_methods"("gatewayAccount", "status");
|
|
86
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "subscriber_payment_methods_gatewayAccount_paymentMethodRef_key"
|
|
87
|
+
ON "subscriber_payment_methods"("gatewayAccount", "paymentMethodRef");
|
|
88
|
+
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_payment_methods_active_per_subscriber
|
|
89
|
+
ON subscriber_payment_methods ("subscriberId") WHERE "status" = 'ACTIVE';
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS "subscriber_payment_method_setups" (
|
|
92
|
+
"id" TEXT NOT NULL,
|
|
93
|
+
"subscriberId" TEXT NOT NULL,
|
|
94
|
+
"gatewayAccount" TEXT NOT NULL,
|
|
95
|
+
"sessionRef" TEXT NOT NULL,
|
|
96
|
+
"customerRef" TEXT NOT NULL,
|
|
97
|
+
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
98
|
+
"completedAt" TIMESTAMP(3),
|
|
99
|
+
CONSTRAINT "subscriber_payment_method_setups_pkey" PRIMARY KEY ("id")
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS "subscriber_payment_method_setups_subscriberId_idx"
|
|
102
|
+
ON "subscriber_payment_method_setups"("subscriberId");
|
|
103
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "subscriber_payment_method_setups_gatewayAccount_sessionRef_key"
|
|
104
|
+
ON "subscriber_payment_method_setups"("gatewayAccount", "sessionRef");
|
|
105
|
+
|
|
106
|
+
-- `ADD CONSTRAINT` has no `IF NOT EXISTS`.
|
|
107
|
+
IF NOT EXISTS (
|
|
108
|
+
SELECT 1 FROM pg_constraint
|
|
109
|
+
WHERE conrelid = to_regclass('subscriber_payment_methods')
|
|
110
|
+
AND conname = 'subscriber_payment_methods_subscriberId_fkey'
|
|
111
|
+
) THEN
|
|
112
|
+
ALTER TABLE "subscriber_payment_methods"
|
|
113
|
+
ADD CONSTRAINT "subscriber_payment_methods_subscriberId_fkey"
|
|
114
|
+
FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
|
|
115
|
+
ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
116
|
+
END IF;
|
|
117
|
+
IF NOT EXISTS (
|
|
118
|
+
SELECT 1 FROM pg_constraint
|
|
119
|
+
WHERE conrelid = to_regclass('subscriber_payment_method_setups')
|
|
120
|
+
AND conname = 'subscriber_payment_method_setups_subscriberId_fkey'
|
|
121
|
+
) THEN
|
|
122
|
+
ALTER TABLE "subscriber_payment_method_setups"
|
|
123
|
+
ADD CONSTRAINT "subscriber_payment_method_setups_subscriberId_fkey"
|
|
124
|
+
FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
|
|
125
|
+
ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
126
|
+
END IF;
|
|
127
|
+
END IF;
|
|
128
|
+
|
|
129
|
+
-- 2. Gateway events, per account ------------------------------------------
|
|
130
|
+
|
|
131
|
+
IF to_regclass('"PaymentEventLog"') IS NULL THEN
|
|
132
|
+
RAISE NOTICE 'PaymentEventLog is not present — an installation without self-registration; left alone.';
|
|
133
|
+
ELSE
|
|
134
|
+
ALTER TABLE "PaymentEventLog" ADD COLUMN IF NOT EXISTS "gatewayAccount" TEXT;
|
|
135
|
+
UPDATE "PaymentEventLog" SET "gatewayAccount" = "provider" WHERE "gatewayAccount" IS NULL;
|
|
136
|
+
ALTER TABLE "PaymentEventLog" ALTER COLUMN "gatewayAccount" SET NOT NULL;
|
|
137
|
+
DROP INDEX IF EXISTS "PaymentEventLog_eventId_key";
|
|
138
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "PaymentEventLog_gatewayAccount_eventId_key"
|
|
139
|
+
ON "PaymentEventLog"("gatewayAccount", "eventId");
|
|
140
|
+
SELECT "gatewayAccount", "sessionId", count(*) AS claims
|
|
141
|
+
INTO twice
|
|
142
|
+
FROM "PaymentEventLog"
|
|
143
|
+
WHERE "status" = 'payment-method-confirmed'
|
|
144
|
+
AND "sessionId" IS NOT NULL
|
|
145
|
+
GROUP BY "gatewayAccount", "sessionId"
|
|
146
|
+
HAVING count(*) > 1
|
|
147
|
+
LIMIT 1;
|
|
148
|
+
IF FOUND THEN
|
|
149
|
+
RAISE EXCEPTION
|
|
150
|
+
'PaymentEventLog holds % confirmations of session % at account %, and one session is confirmed once. Only a database run against an unreleased build of this change can hold them. Keep the confirmation that was acted on, delete the others, and apply this file again.',
|
|
151
|
+
twice.claims, twice."sessionId", twice."gatewayAccount";
|
|
152
|
+
END IF;
|
|
153
|
+
CREATE UNIQUE INDEX IF NOT EXISTS payment_event_log_confirmation_per_session
|
|
154
|
+
ON "PaymentEventLog"("gatewayAccount", "sessionId")
|
|
155
|
+
WHERE "status" = 'payment-method-confirmed';
|
|
156
|
+
END IF;
|
|
157
|
+
|
|
158
|
+
-- 3. Sign-up: billing details and the account a session belongs to -------
|
|
159
|
+
|
|
160
|
+
IF to_regclass('"PendingRegistration"') IS NULL THEN
|
|
161
|
+
RAISE NOTICE 'PendingRegistration is not present — an installation without self-registration; left alone.';
|
|
162
|
+
ELSE
|
|
163
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "addressLine1" TEXT;
|
|
164
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "addressLine2" TEXT;
|
|
165
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "postalCode" TEXT;
|
|
166
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "city" TEXT;
|
|
167
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "country" TEXT;
|
|
168
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "vatId" TEXT;
|
|
169
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "taxNumber" TEXT;
|
|
170
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "checkoutGatewayAccount" TEXT;
|
|
171
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "gatewayCustomerRef" TEXT;
|
|
172
|
+
CREATE INDEX IF NOT EXISTS "PendingRegistration_checkoutGatewayAccount_checkoutSessionI_idx"
|
|
173
|
+
ON "PendingRegistration"("checkoutGatewayAccount", "checkoutSessionId");
|
|
174
|
+
END IF;
|
|
175
|
+
END $$;
|
|
176
|
+
|
|
177
|
+
COMMIT;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- SaaSiCat 1.0 — a promo code's slot is held from the start of a checkout.
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
--
|
|
5
|
+
-- A sign-up that starts its checkout with a promo code keeps a slot of that
|
|
6
|
+
-- code until the checkout concludes, or until a confirmation of its payment
|
|
7
|
+
-- form can no longer arrive, so that a customer who reaches the payment form is
|
|
8
|
+
-- not refused afterwards because somebody else took the code's last redemption
|
|
9
|
+
-- in between. Run it the same way as the other files in
|
|
10
|
+
-- this directory, before `db push` where you use one:
|
|
11
|
+
--
|
|
12
|
+
-- psql "$DATABASE_URL" -f 1.0-a-promo-slot-is-held-through-checkout.postgres.sql
|
|
13
|
+
--
|
|
14
|
+
-- What it touches, in one transaction, and only where `promo_codes` is there:
|
|
15
|
+
--
|
|
16
|
+
-- 1. `promo_codes` — gains `heldCount`, the slots held for checkouts, next to
|
|
17
|
+
-- `redemptionsCount`. Every existing code starts at 0: no checkout held a
|
|
18
|
+
-- slot before this file.
|
|
19
|
+
-- 2. `promo_code_holds` — created with its indexes and its foreign key to
|
|
20
|
+
-- `promo_codes`. One row per checkout offer holding a slot; the row is
|
|
21
|
+
-- deleted when the hold ends, so the table stays small.
|
|
22
|
+
--
|
|
23
|
+
-- Safe to run again: the column, the table and the indexes are created only
|
|
24
|
+
-- where they are missing, and the foreign key only where its name is not taken.
|
|
25
|
+
-- On a database created from `reference-schema.postgres.sql` the whole file does
|
|
26
|
+
-- nothing at all.
|
|
27
|
+
|
|
28
|
+
BEGIN;
|
|
29
|
+
|
|
30
|
+
DO $$
|
|
31
|
+
BEGIN
|
|
32
|
+
IF to_regclass('promo_codes') IS NULL THEN
|
|
33
|
+
RAISE NOTICE 'promo_codes is not present — nothing to do; this installation takes no promo codes.';
|
|
34
|
+
RETURN;
|
|
35
|
+
END IF;
|
|
36
|
+
|
|
37
|
+
ALTER TABLE "promo_codes" ADD COLUMN IF NOT EXISTS "heldCount" INTEGER NOT NULL DEFAULT 0;
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS "promo_code_holds" (
|
|
40
|
+
"id" TEXT NOT NULL,
|
|
41
|
+
"promoCodeId" TEXT NOT NULL,
|
|
42
|
+
"checkoutOfferId" TEXT NOT NULL,
|
|
43
|
+
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
44
|
+
"handedOverTx" BIGINT,
|
|
45
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
46
|
+
|
|
47
|
+
CONSTRAINT "promo_code_holds_pkey" PRIMARY KEY ("id")
|
|
48
|
+
);
|
|
49
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "promo_code_holds_checkoutOfferId_key"
|
|
50
|
+
ON "promo_code_holds"("checkoutOfferId");
|
|
51
|
+
CREATE INDEX IF NOT EXISTS "promo_code_holds_promoCodeId_expiresAt_idx"
|
|
52
|
+
ON "promo_code_holds"("promoCodeId", "expiresAt");
|
|
53
|
+
CREATE INDEX IF NOT EXISTS "promo_code_holds_expiresAt_idx"
|
|
54
|
+
ON "promo_code_holds"("expiresAt");
|
|
55
|
+
|
|
56
|
+
-- `ADD CONSTRAINT` has no `IF NOT EXISTS`.
|
|
57
|
+
IF NOT EXISTS (
|
|
58
|
+
SELECT 1 FROM pg_constraint
|
|
59
|
+
WHERE conrelid = to_regclass('promo_code_holds')
|
|
60
|
+
AND conname = 'promo_code_holds_promoCodeId_fkey'
|
|
61
|
+
) THEN
|
|
62
|
+
ALTER TABLE "promo_code_holds"
|
|
63
|
+
ADD CONSTRAINT "promo_code_holds_promoCodeId_fkey"
|
|
64
|
+
FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id")
|
|
65
|
+
ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
66
|
+
END IF;
|
|
67
|
+
END $$;
|
|
68
|
+
|
|
69
|
+
COMMIT;
|