@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.
- package/admin-api.openapi.yaml +339 -1
- package/package.json +1 -1
- package/prisma-fragments/08-subscription-contract.prisma +2 -0
- package/prisma-fragments/13-subscriber.prisma +1 -0
- package/prisma-fragments/15-subscriber-ledger.prisma +1 -0
- package/prisma-fragments/20-subscription-invoice.prisma +145 -0
- package/prisma-fragments/21-feature-withdrawal.prisma +56 -0
- package/prisma-fragments/22-subscription-invoice-archive.prisma +34 -0
- package/prisma-fragments/README.md +24 -21
- package/schemas/admin-manifest.schema.json +2 -1
- package/schemas/plan-catalog.schema.json +32 -1
- package/schemas/subscriber-ledger.schema.json +3 -2
- package/sql/1.0-a-correction-carries-its-order.postgres.sql +27 -4
- package/sql/1.0-a-feature-is-withdrawn.postgres.sql +49 -0
- package/sql/1.0-an-invoice-document-is-archived.postgres.sql +123 -0
- package/sql/1.0-an-invoice-is-issued.postgres.sql +171 -0
- package/sql/constraints.postgres.sql +7 -0
- package/sql/reference-schema.postgres.sql +159 -0
|
@@ -28,27 +28,30 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
|
|
|
28
28
|
|
|
29
29
|
## Files
|
|
30
30
|
|
|
31
|
-
| File
|
|
32
|
-
|
|
|
33
|
-
| [`01-subscription.prisma`](01-subscription.prisma)
|
|
34
|
-
| [`02-promo-code.prisma`](02-promo-code.prisma)
|
|
35
|
-
| [`03-plan-versions.prisma`](03-plan-versions.prisma)
|
|
36
|
-
| [`04-audit-log.prisma`](04-audit-log.prisma)
|
|
37
|
-
| [`05-bundle.prisma`](05-bundle.prisma)
|
|
38
|
-
| [`06-catalog-entries.prisma`](06-catalog-entries.prisma)
|
|
39
|
-
| [`07-promotion.prisma`](07-promotion.prisma)
|
|
40
|
-
| [`08-subscription-contract.prisma`](08-subscription-contract.prisma)
|
|
41
|
-
| [`09-pending-registration.prisma`](09-pending-registration.prisma)
|
|
42
|
-
| [`10-super-admin.prisma`](10-super-admin.prisma)
|
|
43
|
-
| [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma)
|
|
44
|
-
| [`12-applied-settings.prisma`](12-applied-settings.prisma)
|
|
45
|
-
| [`13-subscriber.prisma`](13-subscriber.prisma)
|
|
46
|
-
| [`14-payments.prisma`](14-payments.prisma)
|
|
47
|
-
| [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma)
|
|
48
|
-
| [`16-maintenance-window.prisma`](16-maintenance-window.prisma)
|
|
49
|
-
| [`17-subscription-notice.prisma`](17-subscription-notice.prisma)
|
|
50
|
-
| [`18-version-retirement.prisma`](18-version-retirement.prisma)
|
|
51
|
-
| [`19-bundle-version-retirement.prisma`](19-bundle-version-retirement.prisma)
|
|
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": {
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|