@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.
@@ -29,7 +29,7 @@ openapi: 3.1.0
29
29
 
30
30
  info:
31
31
  title: SaaS Platform SuperAdmin API
32
- version: 1.0.0-rc.13
32
+ version: 1.0.0-rc.15
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.13",
3
+ "version": "1.0.0-rc.15",
4
4
  "description": "Language-neutral spec of the SaaS platform: JSON Schemas, OpenAPI contract, Prisma fragments, acceptance scenarios.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -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
- - **App-specific tables** (e.g. invoice or bank master data)
129
- belong in the schema of the consuming app, not in the platform.
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": { "type": "number", "minimum": 0, "maximum": 100 },
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
- "description": "VAT rate in percent. Required (even when 0)."
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 is not guessed at, it reads
17
- -- the fact one level up. "taxAmount" is the gap between the line's own net and
18
- -- gross, which is exact: both are already held to two places.
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 needs its unit read rather than assumed. A contract concluded from a
21
- -- checkout offer holds the rate as the offer stated it, and an offer prices its
22
- -- lines as `net * (1 + vatRate)` a fraction while a contract frozen from
23
- -- the catalogue holds per cent. The same installation therefore has both in one
24
- -- column, which is the reason `"taxRate"` exists.
25
- --
26
- -- Which unit a snapshot holds is read off that snapshot's own totals: whichever
27
- -- of the two readings explains the gross it recorded is the one it was written
28
- -- in. Where both explain it a contract for a free plan, whose totals are zero,
29
- -- and every rate explains zero the contract's own provenance decides:
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
- coalesce(
85
- li."taxRate",
86
- CASE
87
- WHEN jsonb_typeof(c."priceSnapshot" -> 'vatRate') = 'number'
88
- AND jsonb_typeof(c."priceSnapshot" -> 'totalNet') = 'number'
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 tax_rate IS NULL
152
- OR tax_rate < 0
153
- OR tax_rate > 100;
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 the vatRate, totalNet and totalGross this needs as '
159
- 'numbers, or yields a rate outside 0-100 (%). The snapshot is the only record of what '
160
- 'was agreed, so this migration will not guess. Repair those snapshots and run it '
161
- 'again. A line named on its own has no contract row at all.',
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;