@saasicat/spec 1.0.0-rc.25 → 1.0.0-rc.26

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.
@@ -28,27 +28,30 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
28
28
 
29
29
  ## Files
30
30
 
31
- | File | Models |
32
- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
33
- | [`01-subscription.prisma`](01-subscription.prisma) | `Subscription`, `CheckoutOffer` + Enums |
34
- | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeHold`, `PromoCodeValidationLog` + Enums |
35
- | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
36
- | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
37
- | [`05-bundle.prisma`](05-bundle.prisma) | `Bundle`, `BundleVersion` |
38
- | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | Capability, feature, quota and marketing catalog models |
39
- | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
40
- | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
41
- | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration` + `RegistrationStatus` |
42
- | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
- | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
44
- | [`12-applied-settings.prisma`](12-applied-settings.prisma) | `AppliedSettings`, `SettingsChange` |
45
- | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection`, `SubscriberTaxOriginChange`, `SubscriberVatIdCheck` |
46
- | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
47
- | [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma) | `SubscriberLedgerEntry` |
48
- | [`16-maintenance-window.prisma`](16-maintenance-window.prisma) | `MaintenanceWindow` |
49
- | [`17-subscription-notice.prisma`](17-subscription-notice.prisma) | `SubscriptionNotice` |
50
- | [`18-version-retirement.prisma`](18-version-retirement.prisma) | `VersionRetirement` |
51
- | [`19-bundle-version-retirement.prisma`](19-bundle-version-retirement.prisma) | `BundleVersionRetirement` |
31
+ | File | Models |
32
+ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
33
+ | [`01-subscription.prisma`](01-subscription.prisma) | `Subscription`, `CheckoutOffer` + Enums |
34
+ | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeHold`, `PromoCodeValidationLog` + Enums |
35
+ | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
36
+ | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
37
+ | [`05-bundle.prisma`](05-bundle.prisma) | `Bundle`, `BundleVersion` |
38
+ | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | Capability, feature, quota and marketing catalog models |
39
+ | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
40
+ | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
41
+ | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration` + `RegistrationStatus` |
42
+ | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
+ | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
44
+ | [`12-applied-settings.prisma`](12-applied-settings.prisma) | `AppliedSettings`, `SettingsChange` |
45
+ | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection`, `SubscriberTaxOriginChange`, `SubscriberVatIdCheck` |
46
+ | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
47
+ | [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma) | `SubscriberLedgerEntry` |
48
+ | [`16-maintenance-window.prisma`](16-maintenance-window.prisma) | `MaintenanceWindow` |
49
+ | [`17-subscription-notice.prisma`](17-subscription-notice.prisma) | `SubscriptionNotice` |
50
+ | [`18-version-retirement.prisma`](18-version-retirement.prisma) | `VersionRetirement` |
51
+ | [`19-bundle-version-retirement.prisma`](19-bundle-version-retirement.prisma) | `BundleVersionRetirement` |
52
+ | [`20-subscription-invoice.prisma`](20-subscription-invoice.prisma) | `SubscriptionInvoice`, `SubscriptionInvoiceLine`, `SubscriptionInvoiceNumber`, `SubscriptionInvoiceDocument` |
53
+ | [`21-feature-withdrawal.prisma`](21-feature-withdrawal.prisma) | `FeatureWithdrawal` |
54
+ | [`22-subscription-invoice-archive.prisma`](22-subscription-invoice-archive.prisma) | `SubscriptionInvoiceArchive` |
52
55
 
53
56
  ## How the consumer uses the fragments
54
57
 
@@ -142,7 +142,8 @@
142
142
  "platformEmail": { "$ref": "#/$defs/StandardPageDef" },
143
143
  "platformEmailHistory": { "$ref": "#/$defs/StandardPageDef" },
144
144
  "settings": { "$ref": "#/$defs/StandardPageDef" },
145
- "maintenance": { "$ref": "#/$defs/StandardPageDef" }
145
+ "maintenance": { "$ref": "#/$defs/StandardPageDef" },
146
+ "featureWithdrawals": { "$ref": "#/$defs/StandardPageDef" }
146
147
  }
147
148
  },
148
149
  "projectPages": {
@@ -258,6 +258,34 @@
258
258
  }
259
259
  }
260
260
  },
261
+ "invoicing": {
262
+ "type": "object",
263
+ "additionalProperties": false,
264
+ "required": ["numberPrefix", "paymentTermDays"],
265
+ "description": "Invoices issued from the charge journal (SC-PRIC-022). Named exactly where the application wires `tenantBilling.chargeJournal.invoices`; the start refuses the one without the other. Needs `tax`, whose adapter decides an invoice's treatment, checks its content and computes its tax, and `issuer`, the party every invoice names.",
266
+ "properties": {
267
+ "numberPrefix": {
268
+ "type": "string",
269
+ "pattern": "^[A-Za-z0-9]{1,16}$",
270
+ "description": "Put in front of every invoice number: `AHP` gives AHP-2026-000123. The installation is one range of its issuer, numbered from 1 again each year as `timeZone` counts it (SC-PRIC-072). Once an invoice exists, the start refuses another prefix (SC-PRIC-024)."
271
+ },
272
+ "paymentTermDays": {
273
+ "type": "integer",
274
+ "minimum": 0,
275
+ "description": "Days from an invoice's issue date to the day it is due (SC-PRIC-046): 14 makes an invoice issued on 1 March due on 15 March; 0 makes it due on the day it is issued. Required, because a term is part of what the subscriber is asked to pay and none is assumed."
276
+ },
277
+ "footer": {
278
+ "type": "array",
279
+ "maxItems": 6,
280
+ "items": {
281
+ "type": "string",
282
+ "pattern": "\\S",
283
+ "maxLength": 120
284
+ },
285
+ "description": "Lines a business letter of the issuer has to state beyond the invoice's own content, printed at the foot of every page of its document: the register court and number and the managing directors of a GmbH (§ 35a GmbHG), a bank account to pay into, and the like — e.g. [\"Amtsgericht München HRB 123456 · Geschäftsführer: Erika Muster\", \"IBAN DE02 1203 0000 0000 2020 51 · BIC BYLADEM1001\"]. An invoice copies them on its issue date, so a later change leaves the invoices issued before as they were (SC-PRIC-077). At most six lines of 120 characters, which is what fits the foot of a page."
286
+ }
287
+ }
288
+ },
261
289
  "payments": {
262
290
  "type": "object",
263
291
  "additionalProperties": false,
@@ -419,5 +447,8 @@
419
447
  }
420
448
  }
421
449
  },
422
- "dependentRequired": { "tax": ["timeZone"] }
450
+ "dependentRequired": {
451
+ "tax": ["timeZone"],
452
+ "invoicing": ["tax", "issuer"]
453
+ }
423
454
  }
@@ -88,9 +88,10 @@
88
88
  "bundleBooking",
89
89
  "bundleChange",
90
90
  "credit",
91
- "correction"
91
+ "correction",
92
+ "reductionTakenBack"
92
93
  ],
93
- "description": "What made the charge arise. activation: the first period of a subscription. renewal: a later period. planChange: what an immediate plan change adds. bundleBooking: the first, short period of an add-on. bundleChange: what an add-on's switch to a dearer version adds for the rest of its period. credit: a decision that reduces what is owed. correction: a counter-entry reversing an earlier charge, which is how the append-only journal answers a mistake."
94
+ "description": "What made the charge arise. activation: the first period of a subscription. renewal: a later period. planChange: what an immediate plan change adds. bundleBooking: the first, short period of an add-on. bundleChange: what an add-on's switch to a dearer version adds for the rest of its period. credit: a decision that reduces what is owed. correction: a counter-entry reversing an earlier charge, which is how the append-only journal answers a mistake. reductionTakenBack: a reduction granted in advance, taken back for the days it no longer covers — the feature it was granted for came back, or what it reduced ended at once."
94
95
  },
95
96
  "SubscriberChargeSource": {
96
97
  "type": "string",
@@ -25,10 +25,19 @@
25
25
  -- index over `correctedAt` gives way to one over `seq`, the order the list is
26
26
  -- read in. On a database created from `reference-schema.postgres.sql` the file
27
27
  -- does nothing at all.
28
+ --
29
+ -- Row-level security. The numbering reaches only the rows this role may read
30
+ -- and update. Where `subscriber_corrections` is under a policy that keeps some
31
+ -- of them from it, the file stops before anything changes: run it as a role
32
+ -- that reaches every row — one that bypasses row-level security, or with
33
+ -- whatever lifts your policy.
28
34
 
29
35
  BEGIN;
30
36
 
31
37
  DO $$
38
+ DECLARE
39
+ renumbered bigint;
40
+ numbered bigint;
32
41
  BEGIN
33
42
  IF to_regclass('subscriber_corrections') IS NULL THEN
34
43
  RAISE NOTICE 'subscriber_corrections is not present — nothing to migrate.';
@@ -56,11 +65,25 @@ BEGIN
56
65
  FROM ordered
57
66
  WHERE c."id" = ordered."id";
58
67
 
59
- PERFORM setval(
60
- pg_get_serial_sequence('subscriber_corrections', 'seq'),
61
- (SELECT COALESCE(MAX("seq"), 0) + 1 FROM "subscriber_corrections"),
62
- false
68
+ -- Adding the column numbered every row, whatever this role may reach, so
69
+ -- the sequence already stands after the last of them. The renumbering
70
+ -- reaches only the rows row-level security lets this role read and update:
71
+ -- a row it misses keeps the number the table's storage order gave it, out
72
+ -- of the order the operator saw, and may share it with a row renumbered.
73
+ -- So the rows renumbered are counted against the numbers handed out.
74
+ GET DIAGNOSTICS renumbered = ROW_COUNT;
75
+ numbered := COALESCE(
76
+ pg_sequence_last_value(pg_get_serial_sequence('subscriber_corrections', 'seq')::regclass),
77
+ 0
63
78
  );
79
+ IF renumbered < numbered THEN
80
+ RAISE EXCEPTION
81
+ 'Cannot reach every row of subscriber_corrections under row-level security as role %: '
82
+ '% of % corrections were renumbered, and the others would keep the order the table '
83
+ 'stores them in. Run this file as a role that may read and update every row. Nothing '
84
+ 'was changed.',
85
+ current_user, renumbered, numbered;
86
+ END IF;
64
87
  END $$;
65
88
 
66
89
  DO $$
@@ -0,0 +1,49 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a feature is withdrawn for a reason outside the platform.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `feature_withdrawals` keeps every feature an operator
6
+ -- withdraws because an external service stopped or a law changed — which
7
+ -- feature, why, from when, the reductions named, when it was lifted, and when
8
+ -- the subscriptions it reaches were told. What it means for each subscription
9
+ -- is a `subscription_notices` row of kind `feature-withdrawn`, which
10
+ -- `1.0-a-subscriber-is-told-once.postgres.sql` creates.
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-a-feature-is-withdrawn.postgres.sql
16
+ --
17
+ -- The partial unique index is what holds a feature to one withdrawal not yet
18
+ -- lifted: every open row of a feature carries the same key, so a second one
19
+ -- collides, and a withdrawal that is lifted leaves the index.
20
+ --
21
+ -- Safe to run again: the table and both indexes are created only where they are
22
+ -- missing. On a database created from `reference-schema.postgres.sql` with
23
+ -- `constraints.postgres.sql` applied, the whole file does nothing at all.
24
+
25
+ BEGIN;
26
+
27
+ CREATE TABLE IF NOT EXISTS "feature_withdrawals" (
28
+ "id" TEXT NOT NULL,
29
+ "featureKey" TEXT NOT NULL,
30
+ "reason" TEXT NOT NULL,
31
+ "effectiveFrom" TIMESTAMP(3) NOT NULL,
32
+ "liftedFrom" TIMESTAMP(3),
33
+ "reductions" JSONB NOT NULL DEFAULT '[]',
34
+ "announcedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
35
+ "announcedBy" TEXT NOT NULL,
36
+ "liftedAt" TIMESTAMP(3),
37
+ "liftedBy" TEXT,
38
+ "toldAt" TIMESTAMP(3),
39
+
40
+ CONSTRAINT "feature_withdrawals_pkey" PRIMARY KEY ("id")
41
+ );
42
+
43
+ CREATE INDEX IF NOT EXISTS "feature_withdrawals_featureKey_idx"
44
+ ON "feature_withdrawals"("featureKey");
45
+
46
+ CREATE UNIQUE INDEX IF NOT EXISTS feature_withdrawals_one_open_per_feature
47
+ ON feature_withdrawals ("featureKey") WHERE "liftedFrom" IS NULL;
48
+
49
+ COMMIT;
@@ -0,0 +1,123 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — an invoice's document is archived and checked.
3
+ -- =============================================================================
4
+ --
5
+ -- Where each invoice's document stands, and the database archive that keeps
6
+ -- the documents (#276 step 6). Needed where `tenantBilling.chargeJournal.invoices`
7
+ -- is configured. Run it AFTER `db push`, and after
8
+ -- `1.0-an-invoice-is-issued.postgres.sql`, whose `subscription_invoices` table
9
+ -- both point at:
10
+ --
11
+ -- psql "$DATABASE_URL" -f 1.0-an-invoice-document-is-archived.postgres.sql
12
+ --
13
+ -- After, because `db push` cannot create triggers, and on a database whose
14
+ -- invoices it has only just created, a run before it finds nothing to protect.
15
+ -- A run before it as well, as an entrypoint applying every shipped file makes,
16
+ -- does no harm; the start refuses invoices until the triggers are in place.
17
+ --
18
+ -- What it touches, in one transaction:
19
+ --
20
+ -- `subscription_invoice_documents` — created with its index and its foreign
21
+ -- key to `subscription_invoices`: the checksum recorded when an invoice's
22
+ -- document was rendered, when the archive confirmed it, which run holds its
23
+ -- mail, and when the mail took it.
24
+ --
25
+ -- `subscription_invoice_archive` — created with its foreign key to
26
+ -- `subscription_invoices`: one file per invoice. And two triggers that refuse
27
+ -- every UPDATE and DELETE of a row and every TRUNCATE of the table, whoever
28
+ -- asks — a TRUNCATE fires no row trigger, and one cascading from
29
+ -- `subscribers` would otherwise empty the archive: an archived invoice is
30
+ -- kept as it was issued. The triggers are the one part of this file a
31
+ -- database created from `reference-schema.postgres.sql` does not have yet,
32
+ -- because that file holds plain statements only.
33
+ --
34
+ -- Skipped, with a notice, where `subscription_invoices` is missing. Nothing is
35
+ -- backfilled: an invoice issued before this ran has no document recorded and
36
+ -- is rendered by the next run.
37
+ --
38
+ -- Safe to run again: every table and index is created only where it is
39
+ -- missing, each foreign key only where its name is not taken, and the trigger
40
+ -- function is replaced and the triggers created again in the same transaction.
41
+
42
+ BEGIN;
43
+
44
+ DO $$
45
+ BEGIN
46
+ IF to_regclass('subscription_invoices') IS NULL THEN
47
+ RAISE NOTICE 'subscription_invoices is not present — the invoice document tables are not created; run 1.0-an-invoice-is-issued.postgres.sql first.';
48
+ RETURN;
49
+ END IF;
50
+
51
+ CREATE TABLE IF NOT EXISTS "subscription_invoice_documents" (
52
+ "invoiceId" TEXT NOT NULL,
53
+ "checksum" TEXT NOT NULL,
54
+ "mediaType" TEXT NOT NULL,
55
+ "fileName" TEXT NOT NULL,
56
+ "size" INTEGER NOT NULL,
57
+ "renderedAt" TIMESTAMP(3) NOT NULL,
58
+ "archivedAt" TIMESTAMP(3),
59
+ "deliveryClaimedAt" TIMESTAMP(3),
60
+ "deliveredAt" TIMESTAMP(3),
61
+ "deliveredTo" TEXT,
62
+ CONSTRAINT "subscription_invoice_documents_pkey" PRIMARY KEY ("invoiceId")
63
+ );
64
+ CREATE INDEX IF NOT EXISTS "subscription_invoice_documents_archivedAt_idx"
65
+ ON "subscription_invoice_documents"("archivedAt");
66
+ IF NOT EXISTS (
67
+ SELECT 1 FROM pg_constraint WHERE conname = 'subscription_invoice_documents_invoiceId_fkey'
68
+ ) THEN
69
+ ALTER TABLE "subscription_invoice_documents"
70
+ ADD CONSTRAINT "subscription_invoice_documents_invoiceId_fkey"
71
+ FOREIGN KEY ("invoiceId") REFERENCES "subscription_invoices"("id")
72
+ ON DELETE RESTRICT ON UPDATE CASCADE;
73
+ END IF;
74
+
75
+ CREATE TABLE IF NOT EXISTS "subscription_invoice_archive" (
76
+ "invoiceId" TEXT NOT NULL,
77
+ "checksum" TEXT NOT NULL,
78
+ "mediaType" TEXT NOT NULL,
79
+ "fileName" TEXT NOT NULL,
80
+ "content" BYTEA NOT NULL,
81
+ "archivedAt" TIMESTAMP(3) NOT NULL,
82
+ CONSTRAINT "subscription_invoice_archive_pkey" PRIMARY KEY ("invoiceId")
83
+ );
84
+ IF NOT EXISTS (
85
+ SELECT 1 FROM pg_constraint WHERE conname = 'subscription_invoice_archive_invoiceId_fkey'
86
+ ) THEN
87
+ ALTER TABLE "subscription_invoice_archive"
88
+ ADD CONSTRAINT "subscription_invoice_archive_invoiceId_fkey"
89
+ FOREIGN KEY ("invoiceId") REFERENCES "subscription_invoices"("id")
90
+ ON DELETE RESTRICT ON UPDATE CASCADE;
91
+ END IF;
92
+ END
93
+ $$;
94
+
95
+ -- Outside the block above, because a function body cannot be created inside
96
+ -- one; guarded the same way, so the file still does nothing without the table.
97
+ CREATE OR REPLACE FUNCTION saasicat_subscription_invoice_archive_is_kept()
98
+ RETURNS trigger
99
+ LANGUAGE plpgsql
100
+ AS $$
101
+ BEGIN
102
+ RAISE EXCEPTION 'An archived invoice document is kept as it was issued: % on subscription_invoice_archive is refused.', TG_OP
103
+ USING ERRCODE = 'restrict_violation';
104
+ END
105
+ $$;
106
+
107
+ DO $$
108
+ BEGIN
109
+ IF to_regclass('subscription_invoice_archive') IS NULL THEN
110
+ RETURN;
111
+ END IF;
112
+ DROP TRIGGER IF EXISTS "subscription_invoice_archive_is_kept" ON "subscription_invoice_archive";
113
+ CREATE TRIGGER "subscription_invoice_archive_is_kept"
114
+ BEFORE UPDATE OR DELETE ON "subscription_invoice_archive"
115
+ FOR EACH ROW EXECUTE FUNCTION saasicat_subscription_invoice_archive_is_kept();
116
+ DROP TRIGGER IF EXISTS "subscription_invoice_archive_is_not_emptied" ON "subscription_invoice_archive";
117
+ CREATE TRIGGER "subscription_invoice_archive_is_not_emptied"
118
+ BEFORE TRUNCATE ON "subscription_invoice_archive"
119
+ FOR EACH STATEMENT EXECUTE FUNCTION saasicat_subscription_invoice_archive_is_kept();
120
+ END
121
+ $$;
122
+
123
+ COMMIT;
@@ -0,0 +1,171 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — an invoice is issued from the charge journal.
3
+ -- =============================================================================
4
+ --
5
+ -- The invoices the platform issues from a subscriber's charges, their lines,
6
+ -- and the one number range they are numbered in (#276 step 6). Needed where
7
+ -- `tenantBilling.chargeJournal.invoices` is configured. Run it BEFORE `db push`,
8
+ -- and after `1.0-a-subscriber-account-records-its-charges.postgres.sql`, whose
9
+ -- `subscriber_ledger_entries` table every line points at:
10
+ --
11
+ -- psql "$DATABASE_URL" -f 1.0-an-invoice-is-issued.postgres.sql
12
+ --
13
+ -- What it touches, in one transaction:
14
+ --
15
+ -- `subscription_invoices` — created with its indexes, among them the unique
16
+ -- number and the unique (numberYear, numberSequence), and its foreign keys to
17
+ -- `subscribers` and `subscription_contracts`; or, where an earlier run of
18
+ -- this file created it, given the `footer` column, empty for the invoices it
19
+ -- already holds.
20
+ --
21
+ -- `subscription_invoice_lines` — created with its indexes, among them the
22
+ -- unique `chargeId` that puts a charge on one invoice only, and its foreign
23
+ -- keys to `subscription_invoices`, `subscriber_ledger_entries` and
24
+ -- `contract_line_items`.
25
+ --
26
+ -- `subscription_invoice_numbers` — created: one row per year, holding the last
27
+ -- number drawn.
28
+ --
29
+ -- Skipped, with a notice, where one of the tables the invoices point at is
30
+ -- missing. Nothing is backfilled: no invoice is issued for a charge before
31
+ -- invoicing is switched on, and the range starts at 1.
32
+ --
33
+ -- Safe to run again: every table, column and index is created only where it
34
+ -- is missing, and each foreign key only where its name is not taken. On a
35
+ -- database created from `reference-schema.postgres.sql` the whole file does
36
+ -- nothing at all.
37
+
38
+ BEGIN;
39
+
40
+ DO $$
41
+ BEGIN
42
+ IF to_regclass('subscribers') IS NULL
43
+ OR to_regclass('subscription_contracts') IS NULL
44
+ OR to_regclass('contract_line_items') IS NULL
45
+ OR to_regclass('subscriber_ledger_entries') IS NULL THEN
46
+ RAISE NOTICE 'subscribers, subscription_contracts, contract_line_items or subscriber_ledger_entries is not present — the invoice tables are not created; an invoice is issued from the charge journal.';
47
+ RETURN;
48
+ END IF;
49
+
50
+ CREATE TABLE IF NOT EXISTS "subscription_invoices" (
51
+ "id" TEXT NOT NULL,
52
+ "number" TEXT NOT NULL,
53
+ "numberPrefix" TEXT NOT NULL,
54
+ "numberYear" INTEGER NOT NULL,
55
+ "numberSequence" INTEGER NOT NULL,
56
+ "tenantId" TEXT NOT NULL,
57
+ "subscriberId" TEXT NOT NULL,
58
+ "subscriptionId" TEXT NOT NULL,
59
+ "contractId" TEXT NOT NULL,
60
+ "issuedAt" TIMESTAMP(3) NOT NULL,
61
+ "issueDate" DATE NOT NULL,
62
+ "dueDate" DATE NOT NULL,
63
+ "servicePeriodFrom" DATE NOT NULL,
64
+ "servicePeriodUntil" DATE NOT NULL,
65
+ "currency" TEXT NOT NULL,
66
+ "issuerParty" JSONB NOT NULL,
67
+ "subscriberParty" JSONB NOT NULL,
68
+ "footer" JSONB NOT NULL DEFAULT '[]',
69
+ "taxTreatment" JSONB NOT NULL,
70
+ "taxes" JSONB NOT NULL,
71
+ "totalNet" DECIMAL(12,2) NOT NULL,
72
+ "totalTax" DECIMAL(12,2) NOT NULL,
73
+ "totalGross" DECIMAL(12,2) NOT NULL,
74
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
75
+ CONSTRAINT "subscription_invoices_pkey" PRIMARY KEY ("id")
76
+ );
77
+ -- A table an earlier run of this file created before invoices carried
78
+ -- their footer gains it here, empty for the invoices it holds.
79
+ ALTER TABLE "subscription_invoices"
80
+ ADD COLUMN IF NOT EXISTS "footer" JSONB NOT NULL DEFAULT '[]';
81
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscription_invoices_number_key"
82
+ ON "subscription_invoices"("number");
83
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscription_invoices_numberYear_numberSequence_key"
84
+ ON "subscription_invoices"("numberYear", "numberSequence");
85
+ CREATE INDEX IF NOT EXISTS "subscription_invoices_subscriberId_issuedAt_idx"
86
+ ON "subscription_invoices"("subscriberId", "issuedAt");
87
+ CREATE INDEX IF NOT EXISTS "subscription_invoices_subscriptionId_idx"
88
+ ON "subscription_invoices"("subscriptionId");
89
+
90
+ CREATE TABLE IF NOT EXISTS "subscription_invoice_lines" (
91
+ "id" TEXT NOT NULL,
92
+ "invoiceId" TEXT NOT NULL,
93
+ "position" INTEGER NOT NULL,
94
+ "chargeId" TEXT NOT NULL,
95
+ "contractLineItemId" TEXT NOT NULL,
96
+ "title" TEXT NOT NULL,
97
+ "origin" TEXT NOT NULL,
98
+ "source" TEXT NOT NULL,
99
+ "periodFrom" DATE NOT NULL,
100
+ "periodUntil" DATE NOT NULL,
101
+ "amountNet" DECIMAL(10,2) NOT NULL,
102
+ "taxRate" DECIMAL(5,2) NOT NULL,
103
+ CONSTRAINT "subscription_invoice_lines_pkey" PRIMARY KEY ("id")
104
+ );
105
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscription_invoice_lines_chargeId_key"
106
+ ON "subscription_invoice_lines"("chargeId");
107
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscription_invoice_lines_invoiceId_position_key"
108
+ ON "subscription_invoice_lines"("invoiceId", "position");
109
+ CREATE INDEX IF NOT EXISTS "subscription_invoice_lines_contractLineItemId_idx"
110
+ ON "subscription_invoice_lines"("contractLineItemId");
111
+
112
+ CREATE TABLE IF NOT EXISTS "subscription_invoice_numbers" (
113
+ "year" INTEGER NOT NULL,
114
+ "last" INTEGER NOT NULL,
115
+ CONSTRAINT "subscription_invoice_numbers_pkey" PRIMARY KEY ("year")
116
+ );
117
+
118
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`.
119
+ IF NOT EXISTS (
120
+ SELECT 1 FROM pg_constraint
121
+ WHERE conrelid = to_regclass('subscription_invoices')
122
+ AND conname = 'subscription_invoices_subscriberId_fkey'
123
+ ) THEN
124
+ ALTER TABLE "subscription_invoices"
125
+ ADD CONSTRAINT "subscription_invoices_subscriberId_fkey"
126
+ FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
127
+ ON DELETE RESTRICT ON UPDATE CASCADE;
128
+ END IF;
129
+ IF NOT EXISTS (
130
+ SELECT 1 FROM pg_constraint
131
+ WHERE conrelid = to_regclass('subscription_invoices')
132
+ AND conname = 'subscription_invoices_contractId_fkey'
133
+ ) THEN
134
+ ALTER TABLE "subscription_invoices"
135
+ ADD CONSTRAINT "subscription_invoices_contractId_fkey"
136
+ FOREIGN KEY ("contractId") REFERENCES "subscription_contracts"("id")
137
+ ON DELETE RESTRICT ON UPDATE CASCADE;
138
+ END IF;
139
+ IF NOT EXISTS (
140
+ SELECT 1 FROM pg_constraint
141
+ WHERE conrelid = to_regclass('subscription_invoice_lines')
142
+ AND conname = 'subscription_invoice_lines_invoiceId_fkey'
143
+ ) THEN
144
+ ALTER TABLE "subscription_invoice_lines"
145
+ ADD CONSTRAINT "subscription_invoice_lines_invoiceId_fkey"
146
+ FOREIGN KEY ("invoiceId") REFERENCES "subscription_invoices"("id")
147
+ ON DELETE RESTRICT ON UPDATE CASCADE;
148
+ END IF;
149
+ IF NOT EXISTS (
150
+ SELECT 1 FROM pg_constraint
151
+ WHERE conrelid = to_regclass('subscription_invoice_lines')
152
+ AND conname = 'subscription_invoice_lines_chargeId_fkey'
153
+ ) THEN
154
+ ALTER TABLE "subscription_invoice_lines"
155
+ ADD CONSTRAINT "subscription_invoice_lines_chargeId_fkey"
156
+ FOREIGN KEY ("chargeId") REFERENCES "subscriber_ledger_entries"("id")
157
+ ON DELETE RESTRICT ON UPDATE CASCADE;
158
+ END IF;
159
+ IF NOT EXISTS (
160
+ SELECT 1 FROM pg_constraint
161
+ WHERE conrelid = to_regclass('subscription_invoice_lines')
162
+ AND conname = 'subscription_invoice_lines_contractLineItemId_fkey'
163
+ ) THEN
164
+ ALTER TABLE "subscription_invoice_lines"
165
+ ADD CONSTRAINT "subscription_invoice_lines_contractLineItemId_fkey"
166
+ FOREIGN KEY ("contractLineItemId") REFERENCES "contract_line_items"("id")
167
+ ON DELETE RESTRICT ON UPDATE CASCADE;
168
+ END IF;
169
+ END $$;
170
+
171
+ COMMIT;
@@ -66,6 +66,13 @@ ALTER TABLE subscription_bundles
66
66
  CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
67
67
  ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
68
68
 
69
+ -- At most ONE withdrawal of a feature is not lifted. A lifted one keeps its row
70
+ -- with `liftedFrom` set and leaves the index, so the feature can be withdrawn
71
+ -- again from that date. Two operators announcing at the same moment therefore
72
+ -- land one withdrawal, because the second insert meets the first.
73
+ CREATE UNIQUE INDEX IF NOT EXISTS feature_withdrawals_one_open_per_feature
74
+ ON feature_withdrawals ("featureKey") WHERE "liftedFrom" IS NULL;
75
+
69
76
  -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
70
77
  -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
71
78
  -- tenants a subscriber had before stay in its history. Two partial unique