@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,73 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a settings change carries the order it was recorded in.
3
+ -- =============================================================================
4
+ --
5
+ -- `settings_changes` gains `seq`, numbered by the database at the write that
6
+ -- records each change, and the platform lists the changes by it. Until now the
7
+ -- list was ordered by `noticedAt`, which is the recording start's own clock: a
8
+ -- start delayed between reading its clock and writing — a pause, a slow
9
+ -- connection — could land after a move made by another start and still be
10
+ -- listed before it, and the list then showed an order the record never went
11
+ -- through. The number is assigned inside the write, under the row lock the
12
+ -- guarded write already takes, so it cannot.
13
+ --
14
+ -- Run it the same way as the other files in this directory, before `db push`
15
+ -- where you use one:
16
+ --
17
+ -- psql "$DATABASE_URL" -f 1.0-a-settings-change-carries-its-order.postgres.sql
18
+ --
19
+ -- An installation without `settings_changes` is left alone. Safe to run again:
20
+ -- the block runs only while the column is missing. Rows
21
+ -- recorded before it existed are numbered in the order they were listed until
22
+ -- now — `noticedAt`, then `id` — so nothing an operator saw changes place, and
23
+ -- the numbering continues after them. The unique index is created afterwards,
24
+ -- because the renumbering passes through values the index would refuse. On a
25
+ -- database created from `reference-schema.postgres.sql` the file does nothing
26
+ -- at all.
27
+
28
+ BEGIN;
29
+
30
+ DO $$
31
+ BEGIN
32
+ IF to_regclass('settings_changes') IS NULL THEN
33
+ RAISE NOTICE 'settings_changes is not present — nothing to migrate.';
34
+ RETURN;
35
+ END IF;
36
+
37
+ IF EXISTS (
38
+ SELECT 1
39
+ FROM information_schema.columns
40
+ WHERE table_schema = current_schema()
41
+ AND table_name = 'settings_changes'
42
+ AND column_name = 'seq'
43
+ ) THEN
44
+ RETURN;
45
+ END IF;
46
+
47
+ ALTER TABLE "settings_changes" ADD COLUMN "seq" SERIAL NOT NULL;
48
+
49
+ WITH ordered AS (
50
+ SELECT "id", row_number() OVER (ORDER BY "noticedAt", "id") AS n
51
+ FROM "settings_changes"
52
+ )
53
+ UPDATE "settings_changes" AS c
54
+ SET "seq" = ordered.n
55
+ FROM ordered
56
+ WHERE c."id" = ordered."id";
57
+
58
+ PERFORM setval(
59
+ pg_get_serial_sequence('settings_changes', 'seq'),
60
+ (SELECT COALESCE(MAX("seq"), 0) + 1 FROM "settings_changes"),
61
+ false
62
+ );
63
+ END $$;
64
+
65
+ DO $$
66
+ BEGIN
67
+ IF to_regclass('settings_changes') IS NULL THEN
68
+ RETURN; -- said once already, by the block above
69
+ END IF;
70
+ CREATE UNIQUE INDEX IF NOT EXISTS "settings_changes_seq_key" ON "settings_changes"("seq");
71
+ END $$;
72
+
73
+ COMMIT;
@@ -0,0 +1,134 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a contract line item records the currency and the tax it was
3
+ -- booked with.
4
+ -- =============================================================================
5
+ --
6
+ -- `contract_line_items` gains three columns: "currency", "taxRate" and
7
+ -- "taxAmount". They are NOT NULL in the shipped fragment, which a plain
8
+ -- `prisma db push` cannot produce on a table that already holds rows — so this
9
+ -- file adds them nullable, fills them, and only then tightens them. Run it
10
+ -- BEFORE `db push`, the same way as the other file in this directory:
11
+ --
12
+ -- psql "$DATABASE_URL" -f 1.0-line-items-record-their-money.postgres.sql
13
+ --
14
+ -- Where the values come from. Every contract carries a `priceSnapshot` holding
15
+ -- the currency and the VAT rate that were agreed for it, written in the same
16
+ -- moment as its line items — so an existing line reads the fact one level up.
17
+ -- "taxAmount" is the gap between the line's own net and gross, which is exact:
18
+ -- both are already held to two places.
19
+ --
20
+ -- The rate is a percentage, as every tax rate in SaaSiCat is: 19 means 19 %.
21
+ -- It is taken from the snapshot as it stands and checked, never read in some
22
+ -- other unit or converted. A contract stops the migration, named, where its
23
+ -- snapshot does not state a currency, does not state its vatRate as a number,
24
+ -- or states a rate outside 0 to 100 or between 0 and 1 — the shape of a
25
+ -- fraction such as 0.19. A line this file fills that already carries a rate of
26
+ -- its own is held to the same rule; a line that already holds all three values
27
+ -- is not touched. An installation that stored fractions converts them before it
28
+ -- runs this file; a row labelled 0.19 % because a number was there is worse
29
+ -- than a migration that did not run.
30
+ --
31
+ -- Safe to run again: the columns are added only where they are missing, a value
32
+ -- already in a column is kept rather than rewritten, and tightening a column
33
+ -- that is already NOT NULL is a no-op. On a database created from
34
+ -- `reference-schema.postgres.sql` the whole file does nothing at all — the
35
+ -- columns are already there, already tight, and there is nothing to fill.
36
+
37
+ BEGIN;
38
+
39
+ DO $$
40
+ DECLARE
41
+ unfillable text[];
42
+ BEGIN
43
+ IF to_regclass('contract_line_items') IS NULL THEN
44
+ RAISE NOTICE 'contract_line_items is not present — nothing to migrate.';
45
+ RETURN; -- an installation that never adopted this fragment
46
+ END IF;
47
+
48
+ ALTER TABLE "contract_line_items" ADD COLUMN IF NOT EXISTS "currency" TEXT;
49
+ ALTER TABLE "contract_line_items" ADD COLUMN IF NOT EXISTS "taxRate" DECIMAL(5,2);
50
+ ALTER TABLE "contract_line_items" ADD COLUMN IF NOT EXISTS "taxAmount" DECIMAL(10,2);
51
+
52
+ -- What each row is to end up holding, worked out once.
53
+ --
54
+ -- Once, because the guard below and the backfill after it have to agree:
55
+ -- two copies of this expression is two chances to answer differently, and
56
+ -- the one that would go unnoticed is the guard passing a row the backfill
57
+ -- then cannot fill.
58
+ --
59
+ -- A value already in a column is kept. The columns are added only where
60
+ -- they are missing, so on a schema that already had a `currency` of its own
61
+ -- this migration reads it rather than overwriting it — and a row that needs
62
+ -- only one of the three is still found, which keying off `currency` alone
63
+ -- would have missed.
64
+ CREATE TEMP TABLE _saasicat_line_money ON COMMIT DROP AS
65
+ SELECT li."id" AS line_id,
66
+ coalesce(c."id", format('line %s', li."id")) AS witness,
67
+ coalesce(
68
+ li."currency",
69
+ CASE
70
+ WHEN jsonb_typeof(c."priceSnapshot" -> 'currency') = 'string'
71
+ THEN nullif(c."priceSnapshot" ->> 'currency', '')
72
+ END
73
+ ) AS currency,
74
+ -- The rate as it is stated, before it is rounded to the column's two
75
+ -- places: the rule is checked on what was written, as the guide's
76
+ -- query checks it, so 0.995 is a fraction here too.
77
+ coalesce(li."taxRate", snap.rate) AS stated_rate,
78
+ coalesce(li."taxRate", round(snap.rate, 2)) AS tax_rate,
79
+ coalesce(li."taxAmount", li."priceGross" - li."priceNet") AS tax_amount
80
+ FROM "contract_line_items" li
81
+ LEFT JOIN "subscription_contracts" c ON c."id" = li."contractId"
82
+ -- The rate is cast only once it is known to be a JSON number, so a
83
+ -- snapshot stating `"vatRate": "19"` reaches the refusal below with its
84
+ -- contract named rather than a cast error that names nothing.
85
+ CROSS JOIN LATERAL (
86
+ SELECT CASE WHEN jsonb_typeof(c."priceSnapshot" -> 'vatRate') = 'number'
87
+ THEN (c."priceSnapshot" ->> 'vatRate')::numeric END AS rate
88
+ ) snap
89
+ WHERE li."currency" IS NULL
90
+ OR li."taxRate" IS NULL
91
+ OR li."taxAmount" IS NULL;
92
+
93
+ -- The contract names itself where there is one. A line whose contract is
94
+ -- missing names itself instead: the shipped fragment declares the foreign
95
+ -- key that makes that impossible, but a schema without it would otherwise
96
+ -- reach the message with nothing to say, and an installation this cannot
97
+ -- fill is exactly the one that needs telling which row to look at.
98
+ SELECT array_agg(DISTINCT witness)
99
+ INTO unfillable
100
+ FROM _saasicat_line_money
101
+ WHERE currency IS NULL
102
+ OR stated_rate IS NULL
103
+ OR stated_rate < 0
104
+ OR stated_rate > 100
105
+ -- The shape of a fraction: a percentage between 0 and 1 is refused
106
+ -- rather than recorded as a fraction of a per cent.
107
+ OR (stated_rate > 0 AND stated_rate < 1);
108
+
109
+ IF unfillable IS NOT NULL THEN
110
+ RAISE EXCEPTION
111
+ 'Cannot record the money facts of % contract(s): their priceSnapshot does not state a '
112
+ 'currency, or does not state its vatRate as a number, or states a tax rate — in the '
113
+ 'snapshot, or on a line that already carries one — that is not a percentage from 0 '
114
+ 'to 100, a value between 0 and 1 being refused as a fraction (%). The snapshot is '
115
+ 'the only record of what was agreed, so this migration converts nothing. Repair those '
116
+ 'rows and run it again. A line named on its own has no contract row at all.',
117
+ array_length(unfillable, 1),
118
+ array_to_string(unfillable[1:10], ', ')
119
+ || CASE WHEN array_length(unfillable, 1) > 10 THEN ', …' ELSE '' END;
120
+ END IF;
121
+
122
+ UPDATE "contract_line_items" li
123
+ SET "currency" = m.currency,
124
+ "taxRate" = m.tax_rate,
125
+ "taxAmount" = m.tax_amount
126
+ FROM _saasicat_line_money m
127
+ WHERE m.line_id = li."id";
128
+
129
+ ALTER TABLE "contract_line_items" ALTER COLUMN "currency" SET NOT NULL;
130
+ ALTER TABLE "contract_line_items" ALTER COLUMN "taxRate" SET NOT NULL;
131
+ ALTER TABLE "contract_line_items" ALTER COLUMN "taxAmount" SET NOT NULL;
132
+ END $$;
133
+
134
+ COMMIT;
@@ -0,0 +1,203 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — remove `projectKey` from the catalogue tables.
3
+ -- =============================================================================
4
+ --
5
+ -- project-key-history: this file names the retired identifier because dropping
6
+ -- it is what it does.
7
+ --
8
+ -- One installation serves one application, so a plan key, a bundle key, a
9
+ -- feature key and a quota key are unique for the installation. The column that
10
+ -- suggested otherwise never had a second value to hold: nothing in the platform
11
+ -- could configure a second project, and `subscriptions.tenantId` is unique
12
+ -- installation-wide, so two applications in one database was never a shape this
13
+ -- model could carry.
14
+ --
15
+ -- Run this against an existing database, inside the transaction it opens. It is
16
+ -- a one-way door — the values are dropped, not archived — but it is safe to run
17
+ -- again: a table whose column has already gone is skipped, so a second run does
18
+ -- nothing rather than failing.
19
+ --
20
+ -- psql "$DATABASE_URL" -f 1.0-remove-project-key.postgres.sql
21
+ --
22
+ -- The first statement is a guard. If the affected tables between them hold rows
23
+ -- under more than one project, the migration STOPS and names the table each key
24
+ -- came from, rather than merging rows nobody meant to merge — two `STANDARD`
25
+ -- plans would otherwise collide on the new unique index, and which of them
26
+ -- survives is not a decision a migration should take.
27
+ --
28
+ -- The question is asked across the tables and not once per table. A per-table
29
+ -- count is one where `plans` holds only `alpha` and `bundles` only `beta`, and
30
+ -- that is a two-project installation whose evidence this migration would drop.
31
+ --
32
+ -- Applies to a database created from `reference-schema.postgres.sql` at 0.27 or
33
+ -- any release before 1.0.
34
+
35
+ BEGIN;
36
+
37
+ DO $$
38
+ DECLARE
39
+ affected CONSTANT text[] := ARRAY[
40
+ 'plans',
41
+ 'bundles',
42
+ 'capability_catalog_entries',
43
+ 'feature_catalog_entries',
44
+ 'quota_catalog_entries',
45
+ 'marketing_projections',
46
+ 'marketing_settings',
47
+ 'promotions',
48
+ 'checkout_offers',
49
+ 'subscription_contracts'
50
+ ];
51
+ target text;
52
+ keys_here text[];
53
+ -- Every key seen anywhere, with the table it came from, so the message can
54
+ -- say where the disagreement is rather than only that there is one.
55
+ seen text[] := ARRAY[]::text[];
56
+ witnesses text[] := ARRAY[]::text[];
57
+ BEGIN
58
+ FOREACH target IN ARRAY affected LOOP
59
+ IF to_regclass(format('%I', target)) IS NULL THEN
60
+ CONTINUE; -- an installation that never adopted this fragment
61
+ END IF;
62
+ -- A table that has already been migrated says nothing, and asking it
63
+ -- would raise `column "projectKey" does not exist`. Everything below
64
+ -- this block is written with IF EXISTS / IF NOT EXISTS, so with this
65
+ -- check the whole file is a no-op on a second run rather than an error
66
+ -- — which is what a deploy that retries, or a container that restarts,
67
+ -- needs it to be.
68
+ IF NOT EXISTS (
69
+ SELECT 1 FROM information_schema.columns
70
+ WHERE table_schema = current_schema()
71
+ AND table_name = target
72
+ AND column_name = 'projectKey'
73
+ ) THEN
74
+ CONTINUE;
75
+ END IF;
76
+ EXECUTE format('SELECT array_agg(DISTINCT "projectKey") FROM %I', target)
77
+ INTO keys_here;
78
+ IF keys_here IS NULL THEN
79
+ CONTINUE; -- an empty table says nothing about the installation
80
+ END IF;
81
+ seen := ARRAY(SELECT DISTINCT unnest(seen || keys_here) ORDER BY 1);
82
+ witnesses := witnesses || format('%s: %s', target, array_to_string(keys_here, ', '));
83
+ END LOOP;
84
+
85
+ IF array_length(seen, 1) > 1 THEN
86
+ RAISE EXCEPTION
87
+ 'The catalogue holds rows under % different project keys (%). Removing the column '
88
+ 'would merge them, and a duplicate key would then collide on the new unique index. '
89
+ 'Per table: %. Decide which rows belong to this installation and delete the rest, '
90
+ 'then run this migration again.',
91
+ array_length(seen, 1),
92
+ array_to_string(seen, ', '),
93
+ array_to_string(witnesses, ' | ');
94
+ END IF;
95
+ END $$;
96
+
97
+ -- ─── The indexes that carried the column ───
98
+
99
+ DROP INDEX IF EXISTS "checkout_offers_projectKey_status_idx";
100
+ DROP INDEX IF EXISTS "plans_projectKey_deletedAt_idx";
101
+ DROP INDEX IF EXISTS "plans_projectKey_planKey_key";
102
+ DROP INDEX IF EXISTS "bundles_projectKey_deletedAt_idx";
103
+ DROP INDEX IF EXISTS "bundles_projectKey_bundleKey_key";
104
+ DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_codeStatus_idx";
105
+ DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_featureKey_idx";
106
+ DROP INDEX IF EXISTS "capability_catalog_entries_projectKey_capabilityKey_key";
107
+ DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_discoveryStatus_idx";
108
+ DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_plannedOnly_idx";
109
+ DROP INDEX IF EXISTS "feature_catalog_entries_projectKey_featureKey_key";
110
+ DROP INDEX IF EXISTS "quota_catalog_entries_projectKey_discoveryStatus_idx";
111
+ DROP INDEX IF EXISTS "quota_catalog_entries_projectKey_quotaKey_key";
112
+ DROP INDEX IF EXISTS "marketing_projections_projectKey_targetType_locale_priority_idx";
113
+ DROP INDEX IF EXISTS "marketing_settings_projectKey_key";
114
+ DROP INDEX IF EXISTS "promotions_projectKey_targetType_validFrom_validTo_idx";
115
+ DROP INDEX IF EXISTS "subscription_contracts_projectKey_status_idx";
116
+
117
+ -- ─── The column, the tightened uniques, and the shrunken lookup indexes ───
118
+ --
119
+ -- Per table, and only where the table is there. The Prisma fragments are
120
+ -- adopted à la carte — `saasicat schema check` reports what an app did not take
121
+ -- — and an unconditional `ALTER TABLE "bundles"` raises `relation does not
122
+ -- exist` on an app that never adopted bundles, which rolls back everything,
123
+ -- including the tables it could have migrated. `IF EXISTS` on the column does
124
+ -- not help when it is the relation that is missing.
125
+ --
126
+ -- One list, so a table and what happens to it cannot drift apart.
127
+
128
+ DO $$
129
+ DECLARE
130
+ step record;
131
+ statement text;
132
+ BEGIN
133
+ FOR step IN
134
+ SELECT * FROM (VALUES
135
+ ('plans', ARRAY[
136
+ 'ALTER TABLE "plans" DROP COLUMN IF EXISTS "projectKey"',
137
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "plans_planKey_key" ON "plans"("planKey")',
138
+ 'CREATE INDEX IF NOT EXISTS "plans_deletedAt_idx" ON "plans"("deletedAt")'
139
+ ]),
140
+ ('bundles', ARRAY[
141
+ 'ALTER TABLE "bundles" DROP COLUMN IF EXISTS "projectKey"',
142
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "bundles_bundleKey_key" ON "bundles"("bundleKey")',
143
+ 'CREATE INDEX IF NOT EXISTS "bundles_deletedAt_idx" ON "bundles"("deletedAt")'
144
+ ]),
145
+ ('capability_catalog_entries', ARRAY[
146
+ 'ALTER TABLE "capability_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
147
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "capability_catalog_entries_capabilityKey_key" ON "capability_catalog_entries"("capabilityKey")',
148
+ 'CREATE INDEX IF NOT EXISTS "capability_catalog_entries_codeStatus_idx" ON "capability_catalog_entries"("codeStatus")',
149
+ 'CREATE INDEX IF NOT EXISTS "capability_catalog_entries_featureKey_idx" ON "capability_catalog_entries"("featureKey")'
150
+ ]),
151
+ ('feature_catalog_entries', ARRAY[
152
+ 'ALTER TABLE "feature_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
153
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "feature_catalog_entries_featureKey_key" ON "feature_catalog_entries"("featureKey")',
154
+ 'CREATE INDEX IF NOT EXISTS "feature_catalog_entries_discoveryStatus_idx" ON "feature_catalog_entries"("discoveryStatus")',
155
+ 'CREATE INDEX IF NOT EXISTS "feature_catalog_entries_plannedOnly_idx" ON "feature_catalog_entries"("plannedOnly")'
156
+ ]),
157
+ ('quota_catalog_entries', ARRAY[
158
+ 'ALTER TABLE "quota_catalog_entries" DROP COLUMN IF EXISTS "projectKey"',
159
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "quota_catalog_entries_quotaKey_key" ON "quota_catalog_entries"("quotaKey")',
160
+ 'CREATE INDEX IF NOT EXISTS "quota_catalog_entries_discoveryStatus_idx" ON "quota_catalog_entries"("discoveryStatus")'
161
+ ]),
162
+ ('marketing_projections', ARRAY[
163
+ 'ALTER TABLE "marketing_projections" DROP COLUMN IF EXISTS "projectKey"',
164
+ 'CREATE INDEX IF NOT EXISTS "marketing_projections_targetType_locale_priority_idx" ON "marketing_projections"("targetType", "locale", "priority")'
165
+ ]),
166
+ ('promotions', ARRAY[
167
+ 'ALTER TABLE "promotions" DROP COLUMN IF EXISTS "projectKey"',
168
+ 'CREATE INDEX IF NOT EXISTS "promotions_targetType_validFrom_validTo_idx" ON "promotions"("targetType", "validFrom", "validTo")'
169
+ ]),
170
+ ('checkout_offers', ARRAY[
171
+ 'ALTER TABLE "checkout_offers" DROP COLUMN IF EXISTS "projectKey"',
172
+ 'CREATE INDEX IF NOT EXISTS "checkout_offers_status_idx" ON "checkout_offers"("status")'
173
+ ]),
174
+ ('subscription_contracts', ARRAY[
175
+ 'ALTER TABLE "subscription_contracts" DROP COLUMN IF EXISTS "projectKey"',
176
+ 'CREATE INDEX IF NOT EXISTS "subscription_contracts_status_idx" ON "subscription_contracts"("status")'
177
+ ]),
178
+ -- `marketing_settings` loses the column and gains the identity that
179
+ -- replaces it: one row, under a constant primary key. The CHECK is
180
+ -- what actually holds it. A default only applies where the caller
181
+ -- omits the id and a primary key accepts every distinct value, so
182
+ -- without it a consumer writing the row directly could make as many
183
+ -- as it liked — which is what the comment claiming otherwise was
184
+ -- worth.
185
+ ('marketing_settings', ARRAY[
186
+ 'ALTER TABLE "marketing_settings" DROP COLUMN IF EXISTS "projectKey"',
187
+ 'ALTER TABLE "marketing_settings" ALTER COLUMN "id" SET DEFAULT ''marketing-settings''',
188
+ 'UPDATE "marketing_settings" SET "id" = ''marketing-settings''',
189
+ 'ALTER TABLE "marketing_settings" DROP CONSTRAINT IF EXISTS "marketing_settings_is_a_singleton"',
190
+ 'ALTER TABLE "marketing_settings" ADD CONSTRAINT "marketing_settings_is_a_singleton" CHECK ("id" = ''marketing-settings'')'
191
+ ])
192
+ ) AS t(relname, statements)
193
+ LOOP
194
+ IF to_regclass(format('%I', step.relname)) IS NULL THEN
195
+ CONTINUE; -- a fragment this installation never adopted
196
+ END IF;
197
+ FOREACH statement IN ARRAY step.statements LOOP
198
+ EXECUTE statement;
199
+ END LOOP;
200
+ END LOOP;
201
+ END $$;
202
+
203
+ COMMIT;
@@ -0,0 +1,67 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — the platform records the configuration it applied.
3
+ -- =============================================================================
4
+ --
5
+ -- Two tables, both additive: `applied_settings` holds the one row that says
6
+ -- which settings the installation applied at its last start, with a fingerprint
7
+ -- and the moment they took effect; `settings_changes` holds one row per start
8
+ -- that found the fingerprint moved. The platform writes both at boot and reads
9
+ -- them for the read-only settings screen — and for nothing else. The record is
10
+ -- a mirror of `config/saas.yaml`, never a source.
11
+ --
12
+ -- Run it the same way as the other files in this directory, before `db push`
13
+ -- where you use one:
14
+ --
15
+ -- psql "$DATABASE_URL" -f 1.0-the-applied-settings-are-recorded.postgres.sql
16
+ --
17
+ -- One row per installation. An installation serves one application and owns
18
+ -- its database, so the row's identity is the installation's, and the CHECK is
19
+ -- what holds it to one row — the constant default only lands a caller that
20
+ -- omits the id on the right one.
21
+ --
22
+ -- A table this file creates has the shape the fragments declare now, the `seq`
23
+ -- that orders the changes included: a fresh installation applies it after
24
+ -- `1.0-a-settings-change-carries-its-order.postgres.sql`, which finds no table
25
+ -- to add the column to, and `db push` refuses to add a unique column to a table
26
+ -- it cannot see is empty.
27
+ --
28
+ -- Safe to run again: both tables and the indexes are created only where they are
29
+ -- missing, and the constraint is dropped before it is added because
30
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`. On a database created from
31
+ -- `reference-schema.postgres.sql` the whole file does nothing at all.
32
+
33
+ BEGIN;
34
+
35
+ CREATE TABLE IF NOT EXISTS "applied_settings" (
36
+ "id" TEXT NOT NULL DEFAULT 'installation',
37
+ "fingerprint" TEXT NOT NULL,
38
+ "settings" JSONB NOT NULL,
39
+ "source" TEXT NOT NULL,
40
+ "appliedAt" TIMESTAMP(3) NOT NULL,
41
+
42
+ CONSTRAINT "applied_settings_pkey" PRIMARY KEY ("id")
43
+ );
44
+
45
+ ALTER TABLE "applied_settings"
46
+ DROP CONSTRAINT IF EXISTS applied_settings_is_a_singleton;
47
+ ALTER TABLE "applied_settings"
48
+ ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
49
+
50
+ CREATE TABLE IF NOT EXISTS "settings_changes" (
51
+ "id" TEXT NOT NULL,
52
+ "seq" SERIAL NOT NULL,
53
+ "noticedAt" TIMESTAMP(3) NOT NULL,
54
+ "source" TEXT NOT NULL,
55
+ "previous" JSONB NOT NULL,
56
+ "current" JSONB NOT NULL,
57
+ "acknowledgedAt" TIMESTAMP(3),
58
+ "acknowledgedBy" TEXT,
59
+
60
+ CONSTRAINT "settings_changes_pkey" PRIMARY KEY ("id")
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS "settings_changes_acknowledgedAt_noticedAt_idx"
64
+ ON "settings_changes"("acknowledgedAt", "noticedAt");
65
+ CREATE UNIQUE INDEX IF NOT EXISTS "settings_changes_seq_key" ON "settings_changes"("seq");
66
+
67
+ COMMIT;
@@ -17,3 +17,79 @@ CREATE UNIQUE INDEX IF NOT EXISTS plan_versions_draft_per_plan
17
17
 
18
18
  CREATE UNIQUE INDEX IF NOT EXISTS bundle_versions_draft_per_bundle
19
19
  ON bundle_versions ("bundleId") WHERE "publishedAt" IS NULL;
20
+
21
+ -- `marketing_settings` holds at most ONE row.
22
+ --
23
+ -- The Prisma DSL cannot say this, and the constant default on `id` does not: a
24
+ -- default applies only where the caller omits the value, and a primary key
25
+ -- accepts every distinct one. A consumer writing the row directly could make as
26
+ -- many as it liked, and the repository would then read one and ignore the rest.
27
+ --
28
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`, and this file is applied again on
29
+ -- every deployment — so it is dropped first. Two plain statements rather than a
30
+ -- dollar-quoted `DO` block on purpose: every consumer of this file splits it at
31
+ -- the statement separator, and a dollar-quoted body would need each of those
32
+ -- splitters to become a SQL lexer. For the same reason no comment in this file
33
+ -- may contain that separator — one here did, and three splitters cut the
34
+ -- sentence in half.
35
+ ALTER TABLE marketing_settings
36
+ DROP CONSTRAINT IF EXISTS marketing_settings_is_a_singleton;
37
+ ALTER TABLE marketing_settings
38
+ ADD CONSTRAINT marketing_settings_is_a_singleton CHECK ("id" = 'marketing-settings');
39
+
40
+ -- `applied_settings` holds at most ONE row: the installation's.
41
+ --
42
+ -- Same reasoning as above, and the same two plain statements. The row mirrors
43
+ -- the settings the installation applied at its last start, and an installation
44
+ -- serves one application, so there is exactly one configuration to record.
45
+ ALTER TABLE applied_settings
46
+ DROP CONSTRAINT IF EXISTS applied_settings_is_a_singleton;
47
+ ALTER TABLE applied_settings
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
+ -- A subscriber has at most ONE payment method in use. The one a newer payment
61
+ -- method replaced keeps its row as `REPLACED`, so it does not count here.
62
+ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_payment_methods_active_per_subscriber
63
+ ON subscriber_payment_methods ("subscriberId") WHERE "status" = 'ACTIVE';
64
+
65
+ -- A gateway session is confirmed ONCE.
66
+ --
67
+ -- A gateway delivers every event at least once, and it may report one session
68
+ -- through more than one event — a completed form and the payment method behind
69
+ -- it can arrive as two. `eventId` tells those apart, so both would be claimed,
70
+ -- and both would record the session's payment method: for a sign-up that means
71
+ -- a second tenant for one registration.
72
+ --
73
+ -- Both adapters claim with `ON CONFLICT DO NOTHING` and no conflict target, so
74
+ -- this index turns the second confirmation into no row rather than an error,
75
+ -- and the caller reads it as the duplicate it is. Partial on the kind, because
76
+ -- a failed setup and an event SaaSiCat does not act on say nothing about the
77
+ -- session being confirmed. A claim carrying no session does not collide:
78
+ -- PostgreSQL counts nulls as distinct in a unique index.
79
+ CREATE UNIQUE INDEX IF NOT EXISTS payment_event_log_confirmation_per_session
80
+ ON "PaymentEventLog" ("gatewayAccount", "sessionId")
81
+ WHERE "status" = 'payment-method-confirmed';
82
+
83
+ -- Customer numbers count from 10001, so a number has five digits up to 99999
84
+ -- and none reads as a count of subscribers. Two statements: the first makes
85
+ -- 10001 where the sequence starts over, which a restart of its identity reads,
86
+ -- and the second moves a sequence that has never handed out a number there. A
87
+ -- second run finds the start already set and the sequence used, and an
88
+ -- installation without the subscriber tables has no such sequence.
89
+ ALTER SEQUENCE IF EXISTS "subscribers_customerSequence_seq" START WITH 10001;
90
+
91
+ SELECT setval(format('%I.%I', schemaname, sequencename)::regclass, 10001, false)
92
+ FROM pg_sequences
93
+ WHERE schemaname = current_schema()
94
+ AND sequencename = 'subscribers_customerSequence_seq'
95
+ AND last_value IS NULL;