@saasicat/spec 1.0.0-rc.13 → 1.0.0-rc.15
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
CHANGED
package/package.json
CHANGED
|
@@ -125,5 +125,12 @@ CREATE UNIQUE INDEX plan_versions_draft_per_plan
|
|
|
125
125
|
- **No add-on tables (#49)** — `subscription_addons`,
|
|
126
126
|
`unit_addon_versions`, `feature_addon_versions` are not a
|
|
127
127
|
sales surface; only plan versions + bundles are sold.
|
|
128
|
-
- **
|
|
129
|
-
|
|
128
|
+
- **The application's own invoicing** — invoices to the people it sells to, fees
|
|
129
|
+
it collects from its members, their bank details — belongs in the schema of the
|
|
130
|
+
consuming app. The subscription business is the platform's: subscribers, their
|
|
131
|
+
invoices and their payments are decided in
|
|
132
|
+
[ADR 0012](../../../docs/explanation/adr/0012-the-subscriber-owns-the-commercial-record.md),
|
|
133
|
+
and every model added for them carries `Subscription` or `Subscriber` in its name,
|
|
134
|
+
which keeps it clear of the names applications use for their own invoicing. A
|
|
135
|
+
prefix cannot rule out a name nobody has seen, so `saasicat schema check` reports
|
|
136
|
+
an application model that carries a platform model's name with a different shape.
|
|
@@ -80,7 +80,13 @@
|
|
|
80
80
|
},
|
|
81
81
|
"hash": { "type": "string", "pattern": "^sha256-[A-Za-z0-9+/=_-]{32,}$" },
|
|
82
82
|
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
|
|
83
|
-
"vatRate": {
|
|
83
|
+
"vatRate": {
|
|
84
|
+
"type": "number",
|
|
85
|
+
"minimum": 0,
|
|
86
|
+
"maximum": 100,
|
|
87
|
+
"not": { "exclusiveMinimum": 0, "exclusiveMaximum": 1 },
|
|
88
|
+
"description": "VAT rate as a percentage: 19 means 19 %."
|
|
89
|
+
},
|
|
84
90
|
"features": {
|
|
85
91
|
"type": "array",
|
|
86
92
|
"items": {
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"type": "number",
|
|
53
53
|
"minimum": 0,
|
|
54
54
|
"maximum": 100,
|
|
55
|
-
"
|
|
55
|
+
"not": { "exclusiveMinimum": 0, "exclusiveMaximum": 1 },
|
|
56
|
+
"description": "VAT rate as a percentage: 19 means 19 %. Required (even when 0). Every tax rate in SaaSiCat is a percentage, so a value between 0 and 1, the shape of a fraction such as 0.19, is refused."
|
|
56
57
|
},
|
|
57
58
|
"tenantBilling": {
|
|
58
59
|
"type": "object",
|
|
@@ -13,30 +13,20 @@
|
|
|
13
13
|
--
|
|
14
14
|
-- Where the values come from. Every contract carries a `priceSnapshot` holding
|
|
15
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
|
|
17
|
-
--
|
|
18
|
-
--
|
|
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
19
|
--
|
|
20
|
-
-- The rate
|
|
21
|
-
--
|
|
22
|
-
--
|
|
23
|
-
--
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
--
|
|
27
|
-
--
|
|
28
|
-
--
|
|
29
|
-
--
|
|
30
|
-
-- `originalOfferId` is set only where the contract was concluded from an offer,
|
|
31
|
-
-- so a null one was frozen from the catalogue and already holds per cent.
|
|
32
|
-
-- Falling back to the fraction there turned an ordinary 19 into 1900 and
|
|
33
|
-
-- stopped the upgrade of any installation that sells a free plan.
|
|
34
|
-
--
|
|
35
|
-
-- What it will not do is invent a value. A contract whose snapshot does not
|
|
36
|
-
-- state a currency, or does not state the numbers this needs, or yields a rate
|
|
37
|
-
-- outside 0–100, stops the migration with a sentence naming the contract —
|
|
38
|
-
-- because a row labelled EUR because EUR is common is worse than a migration
|
|
39
|
-
-- that did not run.
|
|
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.
|
|
40
30
|
--
|
|
41
31
|
-- Safe to run again: the columns are added only where they are missing, a value
|
|
42
32
|
-- already in a column is kept rather than rewritten, and tightening a column
|
|
@@ -81,60 +71,21 @@ BEGIN
|
|
|
81
71
|
THEN nullif(c."priceSnapshot" ->> 'currency', '')
|
|
82
72
|
END
|
|
83
73
|
) AS currency,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
AND jsonb_typeof(c."priceSnapshot" -> 'totalGross') = 'number'
|
|
90
|
-
THEN CASE
|
|
91
|
-
-- The totals say per cent and cannot be read as a
|
|
92
|
-
-- fraction.
|
|
93
|
-
WHEN round(
|
|
94
|
-
(c."priceSnapshot" ->> 'totalNet')::numeric
|
|
95
|
-
* (1 + (c."priceSnapshot" ->> 'vatRate')::numeric / 100),
|
|
96
|
-
2
|
|
97
|
-
) = round((c."priceSnapshot" ->> 'totalGross')::numeric, 2)
|
|
98
|
-
AND round(
|
|
99
|
-
(c."priceSnapshot" ->> 'totalNet')::numeric
|
|
100
|
-
* (1 + (c."priceSnapshot" ->> 'vatRate')::numeric),
|
|
101
|
-
2
|
|
102
|
-
) <> round((c."priceSnapshot" ->> 'totalGross')::numeric, 2)
|
|
103
|
-
THEN round((c."priceSnapshot" ->> 'vatRate')::numeric, 2)
|
|
104
|
-
-- And the other way round. It has to exclude the
|
|
105
|
-
-- per-cent reading in the same way: a total of zero
|
|
106
|
-
-- is explained by both, and without the exclusion
|
|
107
|
-
-- this branch answers first and every free contract
|
|
108
|
-
-- reaches the fraction.
|
|
109
|
-
WHEN round(
|
|
110
|
-
(c."priceSnapshot" ->> 'totalNet')::numeric
|
|
111
|
-
* (1 + (c."priceSnapshot" ->> 'vatRate')::numeric),
|
|
112
|
-
2
|
|
113
|
-
) = round((c."priceSnapshot" ->> 'totalGross')::numeric, 2)
|
|
114
|
-
AND round(
|
|
115
|
-
(c."priceSnapshot" ->> 'totalNet')::numeric
|
|
116
|
-
* (1 + (c."priceSnapshot" ->> 'vatRate')::numeric
|
|
117
|
-
/ 100),
|
|
118
|
-
2
|
|
119
|
-
) <> round((c."priceSnapshot" ->> 'totalGross')::numeric, 2)
|
|
120
|
-
THEN round((c."priceSnapshot" ->> 'vatRate')::numeric * 100, 2)
|
|
121
|
-
-- The totals cannot tell the two apart. A contract
|
|
122
|
-
-- for a free plan is the case that reaches here: its
|
|
123
|
-
-- totals are both zero, so every rate explains them.
|
|
124
|
-
-- The contract says where it came from instead, and
|
|
125
|
-
-- that is a recorded fact rather than an inference —
|
|
126
|
-
-- a contract frozen from the catalogue holds per
|
|
127
|
-
-- cent, one concluded from an offer holds the
|
|
128
|
-
-- fraction the offer priced its lines with.
|
|
129
|
-
WHEN c."originalOfferId" IS NULL
|
|
130
|
-
THEN round((c."priceSnapshot" ->> 'vatRate')::numeric, 2)
|
|
131
|
-
ELSE round((c."priceSnapshot" ->> 'vatRate')::numeric * 100, 2)
|
|
132
|
-
END
|
|
133
|
-
END
|
|
134
|
-
) AS tax_rate,
|
|
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,
|
|
135
79
|
coalesce(li."taxAmount", li."priceGross" - li."priceNet") AS tax_amount
|
|
136
80
|
FROM "contract_line_items" li
|
|
137
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
|
|
138
89
|
WHERE li."currency" IS NULL
|
|
139
90
|
OR li."taxRate" IS NULL
|
|
140
91
|
OR li."taxAmount" IS NULL;
|
|
@@ -148,17 +99,21 @@ BEGIN
|
|
|
148
99
|
INTO unfillable
|
|
149
100
|
FROM _saasicat_line_money
|
|
150
101
|
WHERE currency IS NULL
|
|
151
|
-
OR
|
|
152
|
-
OR
|
|
153
|
-
OR
|
|
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);
|
|
154
108
|
|
|
155
109
|
IF unfillable IS NOT NULL THEN
|
|
156
110
|
RAISE EXCEPTION
|
|
157
111
|
'Cannot record the money facts of % contract(s): their priceSnapshot does not state a '
|
|
158
|
-
'currency, or does not state
|
|
159
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'
|
|
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.',
|
|
162
117
|
array_length(unfillable, 1),
|
|
163
118
|
array_to_string(unfillable[1:10], ', ')
|
|
164
119
|
|| CASE WHEN array_length(unfillable, 1) > 10 THEN ', …' ELSE '' END;
|