@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.
Files changed (36) hide show
  1. package/README.md +30 -0
  2. package/acceptance/README.md +3 -3
  3. package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +6 -6
  4. package/admin-api.openapi.yaml +331 -77
  5. package/cli-conventions.md +19 -19
  6. package/index.cjs +3 -0
  7. package/index.d.cts +3 -1
  8. package/index.d.ts +2 -0
  9. package/index.js +9 -1
  10. package/package.json +4 -2
  11. package/prisma-fragments/01-subscription.prisma +34 -47
  12. package/prisma-fragments/02-promo-code.prisma +38 -0
  13. package/prisma-fragments/03-plan-versions.prisma +3 -4
  14. package/prisma-fragments/05-bundle.prisma +4 -5
  15. package/prisma-fragments/06-catalog-entries.prisma +24 -24
  16. package/prisma-fragments/07-promotion.prisma +2 -3
  17. package/prisma-fragments/08-subscription-contract.prisma +42 -5
  18. package/prisma-fragments/09-pending-registration.prisma +32 -27
  19. package/prisma-fragments/11-subscription-bundle.prisma +17 -0
  20. package/prisma-fragments/12-applied-settings.prisma +55 -0
  21. package/prisma-fragments/13-subscriber.prisma +94 -0
  22. package/prisma-fragments/14-payments.prisma +108 -0
  23. package/prisma-fragments/README.md +40 -22
  24. package/schemas/admin-manifest.schema.json +10 -3
  25. package/schemas/plan-catalog.schema.json +239 -18
  26. package/schemas/promo-code.schema.json +7 -1
  27. package/schemas/tenant-ledger.schema.json +236 -0
  28. package/sql/1.0-a-contract-names-its-subscriber.postgres.sql +383 -0
  29. package/sql/1.0-a-payment-method-is-a-gateway-reference.postgres.sql +177 -0
  30. package/sql/1.0-a-promo-slot-is-held-through-checkout.postgres.sql +69 -0
  31. package/sql/1.0-a-settings-change-carries-its-order.postgres.sql +73 -0
  32. package/sql/1.0-line-items-record-their-money.postgres.sql +134 -0
  33. package/sql/1.0-remove-project-key.postgres.sql +203 -0
  34. package/sql/1.0-the-applied-settings-are-recorded.postgres.sql +67 -0
  35. package/sql/constraints.postgres.sql +76 -0
  36. 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;