@saasicat/spec 1.0.0-rc.22 → 1.0.0-rc.24

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.
@@ -45,8 +45,7 @@ server, and verifies `setup` → `when` → `then` sequentially.
45
45
  | `promo/` | Allow-zero-invoice false blocks a 100% discount | 🔴 |
46
46
  | `promo/` | Code with `valueType=PERCENT, value>100` is rejected | 🔴 |
47
47
  | `plan-version/` | Publish does not touch `Subscription.planVersionId` of existing tenants (P1 guarantee from SPEC §6) | 🟡 stub |
48
- | `plan-version/` | Regressive version requires tenant opt-in | 🔴 |
49
- | `plan-version/` | 6-week notification cron sends mail to affected subscriptions | 🔴 |
48
+ | `plan-version/` | A newer version reaches an existing subscription only as an offer it takes | 🔴 |
50
49
  | `tenant/` | Suspend → 403 for tenant login + audit entry with reason | 🟡 stub |
51
50
  | `tenant/` | Suspend without MFA header → 401 MFA_REQUIRED | 🔴 |
52
51
  | `mfa/` | TOTP verify with a good code → 200, with a bad one → 401 | 🟡 stub |
@@ -3,8 +3,8 @@ description: |
3
3
  P1 guarantee from SPEC §6: When a new plan version is published,
4
4
  `Subscription.planVersionId` of the existing tenants is NOT touched.
5
5
  Only publishedAt of the new version + supersededAt of the old version
6
- are written. Migration runs exclusively through the
7
- notification + renewal cron with a 6-week lead time.
6
+ are written. A subscription moves to the new version only when the
7
+ subscriber takes it as an offer (SC-SUB-021); no renewal moves it.
8
8
 
9
9
  This is the core guarantee for grandfathering protection. If this
10
10
  test goes red, the implementation violates that contract protection.
@@ -43,13 +43,6 @@ then:
43
43
  - assertSubscriptionPriceMonthlyNet:
44
44
  tenantSlug: 'test-existing-contracts'
45
45
  value: 24.90
46
- # pendingPlanVersion fields are not yet set (the notification cron
47
- # has not run yet).
48
- - assertSubscription:
49
- tenantSlug: 'test-existing-contracts'
50
- pendingPlanVersionId: null
51
- pendingPlanVersionEffectiveAt: null
52
-
53
46
  # Catalog: the old version has supersededAt, the new one has publishedAt.
54
47
  - assertPlanVersion:
55
48
  planId: 'STANDARD'
@@ -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.22
32
+ version: 1.0.0-rc.24
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
@@ -58,6 +58,7 @@ tags:
58
58
  - name: manifest
59
59
  - name: catalog
60
60
  - name: settings
61
+ - name: maintenance
61
62
 
62
63
  security:
63
64
  - bearerAuth: []
@@ -351,11 +352,7 @@ paths:
351
352
  properties:
352
353
  planId: { type: string }
353
354
  billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
354
- customLimits:
355
- type: object
356
- additionalProperties:
357
- type: integer
358
- description: 'quotaKey → maxValue (-1 = unlimited)'
355
+ customLimits: { $ref: '#/components/schemas/CustomLimits' }
359
356
  customMonthlyNet: { type: number, format: decimal }
360
357
  isPilot: { type: boolean }
361
358
  pilotEndsAt: { type: string, format: date-time }
@@ -999,7 +996,7 @@ paths:
999
996
  parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1000
997
  post:
1001
998
  tags: [catalog]
1002
- summary: Retire a plan version (endsAt; without a successor version)
999
+ summary: End a plan version's sale (endsAt; without a successor version)
1003
1000
  x-mfa-required: true
1004
1001
  requestBody:
1005
1002
  required: true
@@ -1013,6 +1010,80 @@ paths:
1013
1010
  }
1014
1011
  responses: { '200': { description: Terminated } }
1015
1012
 
1013
+ # ── Retiring a version for running subscriptions (packages/nest/src/billing/version-retirement*) ──
1014
+ #
1015
+ # Served where the manifest carries the capability `planVersions.retire`. The
1016
+ # subscriptions on a version no longer on sale move to a replacement on sale, each at
1017
+ # the first end of a term at least three calendar months after the announcement, and
1018
+ # may cancel without notice until then. Where the operator's terms are not confirmed to
1019
+ # allow it, both routes refuse with `RETIREMENT_TERMS_NOT_CONFIRMED` before reading anything.
1020
+
1021
+ /catalog/plan-versions/{id}/retirement:
1022
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1023
+ get:
1024
+ tags: [catalog]
1025
+ summary: What retiring the version for its running subscriptions would do now
1026
+ parameters:
1027
+ - name: replacement
1028
+ in: query
1029
+ required: true
1030
+ description: The plan version the subscriptions continue on
1031
+ schema: { type: string, format: uuid }
1032
+ responses:
1033
+ '200':
1034
+ description: >-
1035
+ Whom it reaches and when, whom it does not and why, and every blocker the
1036
+ announcement would refuse with
1037
+ content:
1038
+ application/json:
1039
+ schema: { $ref: '#/components/schemas/RetirementPreview' }
1040
+ '404':
1041
+ description: Either version does not exist (`PLAN_VERSION_NOT_FOUND`)
1042
+ '422':
1043
+ description: >-
1044
+ The terms are not confirmed (`RETIREMENT_TERMS_NOT_CONFIRMED`), or the
1045
+ replacement is the version itself (`RETIREMENT_REPLACEMENT_IS_RETIRED`)
1046
+ post:
1047
+ tags: [catalog]
1048
+ summary: Announce the retirement to every subscription it reaches
1049
+ description: >-
1050
+ The announcement and one notice per subscription are recorded in one
1051
+ transaction, then handed to the application's `SubscriptionNoticePort`; a notice
1052
+ that cannot be sent now is sent by the next run. Audited as PLAN_VERSION_RETIRE.
1053
+ x-mfa-required: true
1054
+ requestBody:
1055
+ required: true
1056
+ content:
1057
+ application/json:
1058
+ schema: { $ref: '#/components/schemas/RetirementAnnouncement' }
1059
+ responses:
1060
+ '201':
1061
+ description: Announced
1062
+ content:
1063
+ application/json:
1064
+ schema: { $ref: '#/components/schemas/RetirementAnnounced' }
1065
+ '409':
1066
+ description: >-
1067
+ The subscriptions it reaches are no longer the ones named
1068
+ (`RETIREMENT_PREVIEW_CHANGED`); the body carries the preview as it stands
1069
+ '422':
1070
+ description: >-
1071
+ The first blocker the preview reports (`RETIREMENT_*`), with every blocker
1072
+ beside it
1073
+
1074
+ /catalog/version-retirements:
1075
+ get:
1076
+ tags: [catalog]
1077
+ summary: Every retirement announced, the most recent first
1078
+ responses:
1079
+ '200':
1080
+ description: List
1081
+ content:
1082
+ application/json:
1083
+ schema:
1084
+ type: array
1085
+ items: { $ref: '#/components/schemas/VersionRetirement' }
1086
+
1016
1087
  # ── Bundles ──
1017
1088
  /catalog/bundles:
1018
1089
  get:
@@ -1406,6 +1477,146 @@ paths:
1406
1477
  '404':
1407
1478
  description: No recorded change has this id (`SETTINGS_CHANGE_NOT_FOUND`)
1408
1479
 
1480
+ # ── Maintenance windows (MaintenanceModule, packages/nest/src/maintenance/*) ──
1481
+ #
1482
+ # An operator announces a window, locks the application for a deploy and
1483
+ # unlocks it. The announced times are what tenants are told; they never
1484
+ # start or end the lock. These routes stay reachable while the lock holds.
1485
+ # The status a tenant's page reads is `GET /public/maintenance`, outside
1486
+ # this document's prefix.
1487
+
1488
+ /maintenance:
1489
+ get:
1490
+ tags: [maintenance]
1491
+ summary: The open window, if any, and the ones before it
1492
+ responses:
1493
+ '200':
1494
+ description: The overview, read fresh
1495
+ content:
1496
+ application/json:
1497
+ schema: { $ref: '#/components/schemas/MaintenanceOverview' }
1498
+ post:
1499
+ tags: [maintenance]
1500
+ summary: Announce a window to the tenants
1501
+ description: >-
1502
+ Locks nobody out. Refused while another window is open. The application hears
1503
+ of it through its `MaintenanceNotificationPort`, where it binds one. Audited as
1504
+ MAINTENANCE_WINDOW_ANNOUNCE.
1505
+ requestBody:
1506
+ required: true
1507
+ content:
1508
+ application/json:
1509
+ schema: { $ref: '#/components/schemas/MaintenanceAnnouncement' }
1510
+ responses:
1511
+ '201':
1512
+ description: The window, announced
1513
+ content:
1514
+ application/json:
1515
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1516
+ '400':
1517
+ description: >-
1518
+ A time without its zone (`MAINTENANCE_TIME_INVALID`), an end not after the
1519
+ start (`MAINTENANCE_WINDOW_END_NOT_AFTER_START`) or in the past
1520
+ (`MAINTENANCE_WINDOW_END_IN_PAST`), a message too long
1521
+ (`MAINTENANCE_MESSAGE_TOO_LONG`)
1522
+ '409':
1523
+ description: Another window is open (`MAINTENANCE_WINDOW_ALREADY_OPEN`)
1524
+
1525
+ /maintenance/{id}:
1526
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1527
+ patch:
1528
+ tags: [maintenance]
1529
+ summary: Move the open window, or change its message
1530
+ description: >-
1531
+ What is left out stays; `message: null` takes the message away. A locked window
1532
+ keeps its start and may still move its expected end. Audited as
1533
+ MAINTENANCE_WINDOW_RESCHEDULE.
1534
+ requestBody:
1535
+ required: true
1536
+ content:
1537
+ application/json:
1538
+ schema: { $ref: '#/components/schemas/MaintenanceRevision' }
1539
+ responses:
1540
+ '200':
1541
+ description: The window as it now stands
1542
+ content:
1543
+ application/json:
1544
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1545
+ '400':
1546
+ description: As for announcing
1547
+ '404':
1548
+ description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
1549
+ '409':
1550
+ description: The window is locked and its start cannot move (`MAINTENANCE_WINDOW_LOCKED`)
1551
+
1552
+ /maintenance/{id}/cancel:
1553
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1554
+ post:
1555
+ tags: [maintenance]
1556
+ summary: Call off an announced window
1557
+ description: Audited as MAINTENANCE_WINDOW_CANCEL. A locked window is ended by unlocking it.
1558
+ responses:
1559
+ '201':
1560
+ description: The window, cancelled
1561
+ content:
1562
+ application/json:
1563
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1564
+ '404':
1565
+ description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
1566
+ '409':
1567
+ description: The window is locked (`MAINTENANCE_WINDOW_LOCKED`)
1568
+
1569
+ /maintenance/lock:
1570
+ post:
1571
+ tags: [maintenance]
1572
+ summary: Lock every tenant out
1573
+ description: >-
1574
+ Locks the open window, or opens one locked at once where none is open. Locking what
1575
+ is locked changes nothing and says so. Takes effect in every process within
1576
+ `takesEffectWithinSeconds`. Audited as MAINTENANCE_LOCK.
1577
+ x-mfa-required: true
1578
+ requestBody:
1579
+ required: false
1580
+ content:
1581
+ application/json:
1582
+ schema: { $ref: '#/components/schemas/MaintenanceLockRequest' }
1583
+ responses:
1584
+ '201':
1585
+ description: The lock
1586
+ content:
1587
+ application/json:
1588
+ schema: { $ref: '#/components/schemas/MaintenanceLockView' }
1589
+ '400':
1590
+ description: An end without its zone or in the past
1591
+ '404':
1592
+ description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
1593
+
1594
+ /maintenance/unlock:
1595
+ post:
1596
+ tags: [maintenance]
1597
+ summary: Let tenants back in
1598
+ description: >-
1599
+ Ends the locked window. Where nothing is locked it changes nothing and says so; an
1600
+ announced window is left as it is. Audited as MAINTENANCE_UNLOCK.
1601
+ x-mfa-required: true
1602
+ requestBody:
1603
+ required: false
1604
+ content:
1605
+ application/json:
1606
+ schema:
1607
+ type: object
1608
+ properties:
1609
+ windowId:
1610
+ { type: string, description: The window the operator means }
1611
+ responses:
1612
+ '201':
1613
+ description: What the unlock did
1614
+ content:
1615
+ application/json:
1616
+ schema: { $ref: '#/components/schemas/MaintenanceUnlockView' }
1617
+ '404':
1618
+ description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
1619
+
1409
1620
  # ── Discovery (scan/status, outside /catalog) ──
1410
1621
  /discovery:
1411
1622
  get:
@@ -1514,6 +1725,123 @@ components:
1514
1725
  before: { description: 'Absent where the leaf did not exist before' }
1515
1726
  after: { description: 'Absent where the leaf no longer exists' }
1516
1727
 
1728
+ # ────────── Maintenance windows (packages/core/src/maintenance-window.types.ts) ──────────
1729
+ MaintenanceWindowView:
1730
+ type: object
1731
+ required:
1732
+ [
1733
+ id,
1734
+ status,
1735
+ startsAt,
1736
+ endsAt,
1737
+ message,
1738
+ createdAt,
1739
+ createdBy,
1740
+ lockedAt,
1741
+ lockedBy,
1742
+ endedAt,
1743
+ endedBy,
1744
+ overrun,
1745
+ lapsed,
1746
+ ]
1747
+ properties:
1748
+ id: { type: string }
1749
+ status: { type: string, enum: [announced, locked, ended, cancelled] }
1750
+ startsAt:
1751
+ {
1752
+ type: [string, 'null'],
1753
+ format: date-time,
1754
+ description: 'The announced start; null for a window locked without being announced',
1755
+ }
1756
+ endsAt:
1757
+ {
1758
+ type: [string, 'null'],
1759
+ format: date-time,
1760
+ description: 'The announced, expected end; null where nobody said',
1761
+ }
1762
+ message: { type: [string, 'null'], description: 'Shown to the tenants as written' }
1763
+ createdAt: { type: string, format: date-time }
1764
+ createdBy: { type: string, description: 'An actor tag, as the audit log writes it' }
1765
+ lockedAt: { type: [string, 'null'], format: date-time }
1766
+ lockedBy: { type: [string, 'null'] }
1767
+ endedAt: { type: [string, 'null'], format: date-time }
1768
+ endedBy: { type: [string, 'null'] }
1769
+ overrun: { type: boolean, description: 'Locked, and past its announced end' }
1770
+ lapsed:
1771
+ {
1772
+ type: boolean,
1773
+ description: 'Announced, never locked, and past its announced end: tenants no longer see it',
1774
+ }
1775
+
1776
+ MaintenanceOverview:
1777
+ type: object
1778
+ required: [open, recent, takesEffectWithinSeconds]
1779
+ properties:
1780
+ open:
1781
+ oneOf:
1782
+ - $ref: '#/components/schemas/MaintenanceWindowView'
1783
+ - type: 'null'
1784
+ recent:
1785
+ type: array
1786
+ description: 'Newest first, the open one included'
1787
+ items: { $ref: '#/components/schemas/MaintenanceWindowView' }
1788
+ takesEffectWithinSeconds:
1789
+ { type: integer, description: 'How long a lock takes to reach every process' }
1790
+
1791
+ MaintenanceAnnouncement:
1792
+ type: object
1793
+ required: [startsAt, endsAt]
1794
+ properties:
1795
+ startsAt:
1796
+ {
1797
+ type: string,
1798
+ description: 'Date and time with its zone, e.g. 2026-10-02T22:00+02:00',
1799
+ }
1800
+ endsAt:
1801
+ {
1802
+ type: string,
1803
+ description: 'Date and time with its zone; after startsAt, and ahead',
1804
+ }
1805
+ message: { type: [string, 'null'], maxLength: 500 }
1806
+
1807
+ MaintenanceRevision:
1808
+ type: object
1809
+ properties:
1810
+ startsAt: { type: string, description: 'Date and time with its zone' }
1811
+ endsAt: { type: string, description: 'Date and time with its zone' }
1812
+ message:
1813
+ { type: [string, 'null'], maxLength: 500, description: 'null takes it away' }
1814
+
1815
+ MaintenanceLockRequest:
1816
+ type: object
1817
+ properties:
1818
+ windowId:
1819
+ {
1820
+ type: string,
1821
+ description: 'The window the operator means; left out, the open one or a new one',
1822
+ }
1823
+ endsAt: { type: string, description: 'The expected end, with its zone' }
1824
+ message: { type: [string, 'null'], maxLength: 500 }
1825
+
1826
+ MaintenanceLockView:
1827
+ type: object
1828
+ required: [window, alreadyLocked, takesEffectWithinSeconds]
1829
+ properties:
1830
+ window: { $ref: '#/components/schemas/MaintenanceWindowView' }
1831
+ alreadyLocked:
1832
+ { type: boolean, description: 'The lock already held; nothing changed' }
1833
+ takesEffectWithinSeconds: { type: integer }
1834
+
1835
+ MaintenanceUnlockView:
1836
+ type: object
1837
+ required: [window, wasLocked]
1838
+ properties:
1839
+ window:
1840
+ oneOf:
1841
+ - $ref: '#/components/schemas/MaintenanceWindowView'
1842
+ - type: 'null'
1843
+ wasLocked: { type: boolean }
1844
+
1517
1845
  # ────────── Catalog V2 (DTO = SSOT: packages/nest/src/catalog/dto/*) ──────────
1518
1846
  CatalogPlanCreate:
1519
1847
  type: object
@@ -1667,6 +1995,125 @@ components:
1667
1995
  properties:
1668
1996
  endsAt: { type: string, description: 'ISO-8601; must lie in the future' }
1669
1997
 
1998
+ RetiredVersionRef:
1999
+ type: object
2000
+ required: [planVersionId, planKey, version]
2001
+ properties:
2002
+ planVersionId: { type: string }
2003
+ planKey: { type: string }
2004
+ version: { type: integer }
2005
+
2006
+ RetirementSide:
2007
+ description: 'A version as the subscribers compare it; a price of null is a rhythm it is not sold in'
2008
+ allOf:
2009
+ - $ref: '#/components/schemas/RetiredVersionRef'
2010
+ - type: object
2011
+ required: [features, quotas, monthlyNet, yearlyNet, validUntil, endsAt]
2012
+ properties:
2013
+ features: { type: array, items: { type: string } }
2014
+ quotas: { type: object, additionalProperties: { type: number } }
2015
+ monthlyNet: { type: [number, 'null'] }
2016
+ yearlyNet: { type: [number, 'null'] }
2017
+ validUntil: { type: [string, 'null'] }
2018
+ endsAt: { type: [string, 'null'] }
2019
+
2020
+ RetirementPreview:
2021
+ type: object
2022
+ required: [retired, replacement, changes, asOf, reached, skipped, blockers]
2023
+ properties:
2024
+ retired: { $ref: '#/components/schemas/RetirementSide' }
2025
+ replacement: { $ref: '#/components/schemas/RetirementSide' }
2026
+ changes:
2027
+ type: array
2028
+ description: 'Every difference, retired to replacement, as the catalogue diff states it'
2029
+ items: { type: object }
2030
+ asOf: { type: string, description: 'ISO-8601: the moment it is computed for' }
2031
+ reached:
2032
+ type: array
2033
+ items:
2034
+ type: object
2035
+ required:
2036
+ [
2037
+ tenantId,
2038
+ subscriptionId,
2039
+ status,
2040
+ billingCycle,
2041
+ effectiveAt,
2042
+ lastDayToCancel,
2043
+ reachedRecently,
2044
+ ]
2045
+ properties:
2046
+ tenantId: { type: string }
2047
+ subscriptionId: { type: string }
2048
+ status: { type: string }
2049
+ billingCycle: { type: string }
2050
+ effectiveAt: { type: string, description: 'ISO-8601: a term end' }
2051
+ lastDayToCancel:
2052
+ { type: string, description: 'ISO date, UTC: the day before' }
2053
+ reachedRecently:
2054
+ {
2055
+ type: boolean,
2056
+ description: 'Reached by a retirement within twelve months',
2057
+ }
2058
+ skipped:
2059
+ type: array
2060
+ items:
2061
+ type: object
2062
+ required: [tenantId, subscriptionId, reason]
2063
+ properties:
2064
+ tenantId: { type: string }
2065
+ subscriptionId: { type: string }
2066
+ reason:
2067
+ {
2068
+ type: string,
2069
+ enum:
2070
+ [
2071
+ ended,
2072
+ cancelled-before,
2073
+ changes-before,
2074
+ no-term,
2075
+ already-told,
2076
+ ],
2077
+ }
2078
+ blockers:
2079
+ type: array
2080
+ description: 'Empty where it may be announced as it stands'
2081
+ items:
2082
+ type: object
2083
+ required: [code, message, params]
2084
+ properties:
2085
+ code: { type: string, description: 'RETIREMENT_*' }
2086
+ message: { type: string }
2087
+ params: { type: object }
2088
+
2089
+ RetirementAnnouncement:
2090
+ type: object
2091
+ required: [replacementPlanVersionId, subscriptionIds]
2092
+ properties:
2093
+ replacementPlanVersionId: { type: string, format: uuid }
2094
+ subscriptionIds:
2095
+ type: array
2096
+ description: 'The subscriptions the operator was shown in the preview'
2097
+ items: { type: string }
2098
+
2099
+ VersionRetirement:
2100
+ type: object
2101
+ required: [id, retired, replacement, announcedAt, announcedBy]
2102
+ properties:
2103
+ id: { type: string }
2104
+ retired: { $ref: '#/components/schemas/RetiredVersionRef' }
2105
+ replacement: { $ref: '#/components/schemas/RetiredVersionRef' }
2106
+ announcedAt: { type: string }
2107
+ announcedBy: { type: string }
2108
+
2109
+ RetirementAnnounced:
2110
+ type: object
2111
+ required: [retirement, told, failed]
2112
+ properties:
2113
+ retirement: { $ref: '#/components/schemas/VersionRetirement' }
2114
+ told: { type: integer, description: 'Notices sent at once' }
2115
+ failed: { type: integer, description: 'Notices the next run sends' }
2116
+
1670
2117
  CatalogMarketingProjectionUpdate:
1671
2118
  type: object
1672
2119
  description: 'Partial update of a marketing projection'
@@ -1911,6 +2358,24 @@ components:
1911
2358
  sendWelcomeMail: { type: boolean, default: true }
1912
2359
 
1913
2360
  # ────────── Subscription ──────────
2361
+ CustomLimits:
2362
+ type: object
2363
+ description: >-
2364
+ A tenant's own limits beside its plan — a negotiated contract, a pilot
2365
+ (`CustomLimits` in @saasicat/core). A quota replaces the plan's value for that
2366
+ quota; a feature is added to the plan's. Stored as JSON in
2367
+ `subscriptions.customLimits`; a stored value in any other shape is not applied.
2368
+ additionalProperties: false
2369
+ properties:
2370
+ quotas:
2371
+ type: object
2372
+ additionalProperties:
2373
+ type: integer
2374
+ minimum: -1
2375
+ description: 'quotaKey → maxValue (-1 = unlimited)'
2376
+ features:
2377
+ type: array
2378
+ items: { type: string }
1914
2379
  SubscriptionDetail:
1915
2380
  type: object
1916
2381
  properties:
@@ -1923,9 +2388,7 @@ components:
1923
2388
  canceledAt: { type: string, format: date-time }
1924
2389
  isPilot: { type: boolean }
1925
2390
  pilotEndsAt: { type: string, format: date-time }
1926
- customLimits:
1927
- type: object
1928
- additionalProperties: { type: integer }
2391
+ customLimits: { $ref: '#/components/schemas/CustomLimits' }
1929
2392
  customMonthlyNet: { type: number, format: decimal, nullable: true }
1930
2393
  effectiveLimits:
1931
2394
  type: object
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.22",
3
+ "version": "1.0.0-rc.24",
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",
@@ -89,20 +89,10 @@ model Subscription {
89
89
  // which is the old behaviour for one step and correct for that step.
90
90
  billingAnchorDay Int?
91
91
 
92
- // Plan versioning — binds to a published PlanVersion.
93
- // `pendingPlanVersion*` concern a version change within the
94
- // same plan; `pendingPlan` the change to a different plan. Both
95
- // can coexist.
96
- //
97
- // Every subscription binds a concrete published PlanVersion.
98
- planVersionId String
99
- pendingPlanVersionId String?
100
- pendingPlanVersionEffectiveAt DateTime?
101
- pendingPlanVersionAccepted Boolean @default(false)
102
- pendingPlanVersionAcceptedAt DateTime?
103
- pendingPlanVersionAcceptedByUserId String?
104
- pendingPlanVersionNotifiedAt DateTime?
105
- pendingPlanVersionReminderSentAt DateTime?
92
+ // Plan versioning — binds to a published PlanVersion, and keeps it across
93
+ // renewals: a newer version reaches the subscription as an offer, and a
94
+ // switch to it is a change like any other (`pendingPlan` below).
95
+ planVersionId String
106
96
 
107
97
  // Trial / scheduled plan changes
108
98
  trialEntitlementPlan String? // Default entitlement during TRIAL
@@ -110,10 +100,13 @@ model Subscription {
110
100
  pendingPlan String? // Downgrade/change at period end
111
101
  pendingBillingCycle BillingCycle?
112
102
  pendingEffectiveAt DateTime?
103
+ // The version of `pendingPlan` the change was quoted at, bound when it
104
+ // comes due; null where the plan stays or nothing reads versions.
105
+ pendingChangeVersionId String?
113
106
 
114
107
  // Special contract / pilot — audit (who/when) lives in the AuditLog.
115
108
  customMonthlyNet Decimal? @db.Decimal(10, 2)
116
- customLimits Json? // { maxUsers?, maxVehicles?, maxStorageGb?, features?: string[] }
109
+ customLimits Json? // CustomLimits (@saasicat/core): { quotas?: { <quotaKey>: number }, features?: string[] }
117
110
  customNote String?
118
111
  isPilot Boolean @default(false)
119
112
  pilotEndsAt DateTime?
@@ -134,13 +127,13 @@ model Subscription {
134
127
  // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
135
128
  promoRedemption PromoCodeRedemption? // see 02-promo-code.prisma
136
129
  planVersion PlanVersion @relation("SubscriptionPlanVersion", fields: [planVersionId], references: [id])
137
- pendingPlanVersion PlanVersion? @relation("SubscriptionPendingPlanVersion", fields: [pendingPlanVersionId], references: [id])
130
+ pendingChangeVersion PlanVersion? @relation("SubscriptionPendingChangeVersion", fields: [pendingChangeVersionId], references: [id], onDelete: Restrict)
138
131
  // Standalone add-on bookings (see 11-subscription-bundle.prisma).
139
132
  bundles SubscriptionBundle[]
140
133
 
141
134
  @@index([tenantId])
142
135
  @@index([planVersionId])
143
- @@index([pendingPlanVersionId])
136
+ @@index([pendingChangeVersionId])
144
137
  @@index([currentPeriodEnd])
145
138
  @@map("subscriptions")
146
139
  }
@@ -90,7 +90,7 @@ model PlanVersion {
90
90
  baseVersion PlanVersion? @relation("PlanVersionLineage", fields: [baseVersionId], references: [id])
91
91
  derivedVersions PlanVersion[] @relation("PlanVersionLineage")
92
92
  subscriptionsCurrent Subscription[] @relation("SubscriptionPlanVersion")
93
- subscriptionsPending Subscription[] @relation("SubscriptionPendingPlanVersion")
93
+ subscriptionsPendingChange Subscription[] @relation("SubscriptionPendingChangeVersion")
94
94
 
95
95
  @@unique([planId, version])
96
96
  @@index([planId, supersededAt])
@@ -21,7 +21,8 @@ model AuditLog {
21
21
  entityId String
22
22
  action String // SCREAMING_SNAKE_CASE; e.g. "PROMO_CODE_REDEEM"
23
23
  changes Json? // { field: { old: …, new: … } }
24
- // Origin marker 'web:<email>:<sessionId>' | 'cli:<email>:<host>' — see
24
+ // Origin marker 'web:<email>:<sessionId>' | 'cli:<email>:<host>' |
25
+ // 'job:platform:<job>' — see
25
26
  // audit-event.schema.json `actorTag`. Queryable with a trailing wildcard
26
27
  // ('cli:*') via AuditQueryPort.
27
28
  actorTag String?