@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.
- package/acceptance/README.md +1 -2
- package/acceptance/plan-version/publish-does-not-touch-existing-contracts.yaml +2 -9
- package/admin-api.openapi.yaml +473 -10
- package/package.json +1 -1
- package/prisma-fragments/01-subscription.prisma +10 -17
- package/prisma-fragments/03-plan-versions.prisma +1 -1
- package/prisma-fragments/04-audit-log.prisma +2 -1
- package/prisma-fragments/10-super-admin.prisma +1 -1
- package/prisma-fragments/16-maintenance-window.prisma +47 -0
- package/prisma-fragments/17-subscription-notice.prisma +55 -0
- package/prisma-fragments/18-version-retirement.prisma +44 -0
- package/prisma-fragments/README.md +2 -0
- package/schemas/admin-manifest.schema.json +2 -1
- package/schemas/audit-event.schema.json +2 -2
- package/schemas/plan-catalog.schema.json +13 -1
- package/sql/1.0-a-newer-version-is-only-offered.postgres.sql +33 -0
- package/sql/1.0-a-retirement-is-announced.postgres.sql +43 -0
- package/sql/1.0-a-scheduled-change-keeps-its-quoted-version.postgres.sql +129 -0
- package/sql/1.0-a-subscriber-is-told-once.postgres.sql +48 -0
- package/sql/1.0-maintenance-windows-are-kept.postgres.sql +51 -0
- package/sql/constraints.postgres.sql +9 -0
- package/sql/reference-schema.postgres.sql +75 -9
package/acceptance/README.md
CHANGED
|
@@ -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/` |
|
|
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.
|
|
7
|
-
|
|
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'
|
package/admin-api.openapi.yaml
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
@@ -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
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
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? // {
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
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>'
|
|
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?
|