@saasicat/spec 1.0.0-rc.23 → 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.23
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,
@@ -352,11 +352,7 @@ paths:
352
352
  properties:
353
353
  planId: { type: string }
354
354
  billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
355
- customLimits:
356
- type: object
357
- additionalProperties:
358
- type: integer
359
- description: 'quotaKey → maxValue (-1 = unlimited)'
355
+ customLimits: { $ref: '#/components/schemas/CustomLimits' }
360
356
  customMonthlyNet: { type: number, format: decimal }
361
357
  isPilot: { type: boolean }
362
358
  pilotEndsAt: { type: string, format: date-time }
@@ -1000,7 +996,7 @@ paths:
1000
996
  parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1001
997
  post:
1002
998
  tags: [catalog]
1003
- summary: Retire a plan version (endsAt; without a successor version)
999
+ summary: End a plan version's sale (endsAt; without a successor version)
1004
1000
  x-mfa-required: true
1005
1001
  requestBody:
1006
1002
  required: true
@@ -1014,6 +1010,80 @@ paths:
1014
1010
  }
1015
1011
  responses: { '200': { description: Terminated } }
1016
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
+
1017
1087
  # ── Bundles ──
1018
1088
  /catalog/bundles:
1019
1089
  get:
@@ -1925,6 +1995,125 @@ components:
1925
1995
  properties:
1926
1996
  endsAt: { type: string, description: 'ISO-8601; must lie in the future' }
1927
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
+
1928
2117
  CatalogMarketingProjectionUpdate:
1929
2118
  type: object
1930
2119
  description: 'Partial update of a marketing projection'
@@ -2169,6 +2358,24 @@ components:
2169
2358
  sendWelcomeMail: { type: boolean, default: true }
2170
2359
 
2171
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 }
2172
2379
  SubscriptionDetail:
2173
2380
  type: object
2174
2381
  properties:
@@ -2181,9 +2388,7 @@ components:
2181
2388
  canceledAt: { type: string, format: date-time }
2182
2389
  isPilot: { type: boolean }
2183
2390
  pilotEndsAt: { type: string, format: date-time }
2184
- customLimits:
2185
- type: object
2186
- additionalProperties: { type: integer }
2391
+ customLimits: { $ref: '#/components/schemas/CustomLimits' }
2187
2392
  customMonthlyNet: { type: number, format: decimal, nullable: true }
2188
2393
  effectiveLimits:
2189
2394
  type: object
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.23",
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?
@@ -42,7 +42,7 @@ model SuperAdminMfa {
42
42
  // admins live exclusively in `super_admin_users` may add the relation:
43
43
  // user SuperAdminUser @relation(fields: [userId], references: [id], onDelete: Cascade)
44
44
  userId String @id
45
- secret String? // TOTP secret; null after reset
45
+ secret String? // TOTP secret, sealed by the installation's SecretSealer; null after reset
46
46
  enabledAt DateTime? // set when a secret is stored, null after reset
47
47
 
48
48
  updatedAt DateTime @updatedAt
@@ -0,0 +1,55 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: SubscriptionNotice
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per notice a subscriber is due: that a newer version of their plan is
8
+ // offered to them, or that the version they are on is being retired — each told
9
+ // once. The platform records a notice before it has the
10
+ // application send it and confirms it after, so the unique key below is what
11
+ // makes a notice once — however many instances run the job — and the row is
12
+ // what shows later when it went out, to whom and how.
13
+ //
14
+ // A run takes a notice on by setting `claimedAt`, and lets it go again where the
15
+ // sending failed; a claim older than a quarter of an hour counts as abandoned.
16
+ // `deliveredAt`, `recipients` and `channel` are set together, when the
17
+ // application confirms it sent the notice.
18
+ //
19
+ // The record is evidence of what a subscriber was told, and outlives the
20
+ // subscription like the subscriber's account does (ADR 0012): `tenantId` and
21
+ // `subscriptionId` are kept as values, not as keys.
22
+ //
23
+ // `kind` is text holding a closed set: version-offered, version-retired,
24
+ // version-retirement-reminder.
25
+ //
26
+ // Optional: adopt it where `tenantBilling.versionNotices` is configured, which
27
+ // refuses to start without a place to keep notices; a retirement needs it too.
28
+ //
29
+ // Contract: @saasicat/core (SubscriptionNoticeRepository).
30
+
31
+ model SubscriptionNotice {
32
+ id String @id @default(uuid())
33
+ tenantId String
34
+ subscriptionId String
35
+
36
+ kind String
37
+ // What the notice is about — for version-offered, the id of the version
38
+ // offered; for version-retired and version-retirement-reminder, the id of
39
+ // the version retired, so a subscription is told of a version's retirement
40
+ // once and reminded of it once.
41
+ subject String
42
+ // The notice as it was last handed to the application.
43
+ content Json
44
+
45
+ createdAt DateTime @default(now())
46
+ claimedAt DateTime?
47
+ deliveredAt DateTime?
48
+ // Who it went to and how, in the application's own terms.
49
+ recipients Json?
50
+ channel String?
51
+
52
+ @@unique([subscriptionId, kind, subject])
53
+ @@index([kind, subject])
54
+ @@map("subscription_notices")
55
+ }
@@ -0,0 +1,44 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: VersionRetirement
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per retirement an operator announces: a plan version ended for the
8
+ // subscriptions already on it, with the version they continue on. It is the one
9
+ // exception to a subscription keeping its version, so it is kept as evidence of
10
+ // what was announced, when and by whom.
11
+ //
12
+ // What it means for each subscription it reaches — the date it takes effect,
13
+ // the last day to cancel without notice, and to whom and how that was told — is
14
+ // a `subscription_notices` row of kind version-retired, written in the same
15
+ // transaction: its subject is the retired version, its content names this
16
+ // row's id as `retirementId`.
17
+ //
18
+ // The versions are kept as values, not as keys: the record outlives the
19
+ // catalogue entries it names, as the notices do (ADR 0012).
20
+ //
21
+ // Optional: adopt it where an operator may retire versions
22
+ // (`tenantBilling.orderlyRetirement` in config/saas.yaml), which refuses to
23
+ // start without a place to keep announcements.
24
+ //
25
+ // Contract: @saasicat/core (VersionRetirementRepository).
26
+
27
+ model VersionRetirement {
28
+ id String @id @default(uuid())
29
+
30
+ retiredPlanVersionId String
31
+ retiredPlanKey String
32
+ retiredVersion Int
33
+
34
+ replacementPlanVersionId String
35
+ replacementPlanKey String
36
+ replacementVersion Int
37
+
38
+ announcedAt DateTime @default(now())
39
+ // Who announced it — an actor tag, as the audit log writes it.
40
+ announcedBy String
41
+
42
+ @@index([retiredPlanVersionId])
43
+ @@map("version_retirements")
44
+ }
@@ -46,6 +46,7 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
46
46
  | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
47
47
  | [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma) | `SubscriberLedgerEntry` |
48
48
  | [`16-maintenance-window.prisma`](16-maintenance-window.prisma) | `MaintenanceWindow` |
49
+ | [`17-subscription-notice.prisma`](17-subscription-notice.prisma) | `SubscriptionNotice` |
49
50
 
50
51
  ## How the consumer uses the fragments
51
52
 
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "actorTag": {
40
40
  "type": ["string", "null"],
41
- "description": "Origin marker. Format: 'web:<email>:<sessionId>' or 'cli:<email>:<host>'."
41
+ "description": "Origin marker. Format: 'web:<email>:<sessionId>', 'cli:<email>:<host>', or 'job:platform:<job>' for a platform job acting on its own schedule."
42
42
  },
43
43
  "ipAddress": { "type": ["string", "null"] },
44
44
  "userAgent": { "type": ["string", "null"] },
@@ -48,7 +48,7 @@
48
48
  "$defs": {
49
49
  "ActorTagPattern": {
50
50
  "type": "string",
51
- "pattern": "^(web|cli):[^:]+:[^:]+$",
51
+ "pattern": "^(web|cli|job):[^:]+:[^:]+$",
52
52
  "description": "Mandatory format for actorTag."
53
53
  },
54
54
 
@@ -59,7 +59,7 @@
59
59
  "type": "object",
60
60
  "additionalProperties": false,
61
61
  "description": "Commercial settings for the tenant-facing self-service routes. Required, and required member by member: every one of these has a money or a legal consequence, and a value left out is still a decision — just an invisible one. The file is read at boot, so a change lands on the next restart.",
62
- "required": ["cancellationNoticeDays", "selfServiceBlockedPlans"],
62
+ "required": ["cancellationNoticeDays", "selfServiceBlockedPlans", "orderlyRetirement"],
63
63
  "properties": {
64
64
  "cancellationNoticeDays": {
65
65
  "type": "object",
@@ -100,6 +100,18 @@
100
100
  "description": "Plan IDs that may not be left via self-service — typically an active special contract, whose change goes through sales."
101
101
  }
102
102
  }
103
+ },
104
+ "orderlyRetirement": {
105
+ "type": "object",
106
+ "additionalProperties": false,
107
+ "description": "Whether the operator may retire a plan version for the subscriptions already on it — end it, or raise its price, with three calendar months of notice and a named replacement. It reaches contracts customers have already made, so it rests on a clause in the operator's terms, legally reviewed; without one only the rule that a subscription keeps its version applies.",
108
+ "required": ["termsConfirmed"],
109
+ "properties": {
110
+ "termsConfirmed": {
111
+ "type": "boolean",
112
+ "description": "true states that the operator's terms carry that clause. Only then does the admin offer a retirement and the server accept one; false refuses it."
113
+ }
114
+ }
103
115
  }
104
116
  }
105
117
  },
@@ -0,0 +1,33 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a newer version is only offered.
3
+ -- =============================================================================
4
+ --
5
+ -- A subscription keeps the plan version it is bound to, and a newer one reaches
6
+ -- it as an offer beside the plan (`SC-SUB-020`); taking it is a change like any
7
+ -- other. The pending version — announced, reminded about, accepted and rolled
8
+ -- forward at the end of the term — is gone, and so are the seven columns that
9
+ -- carried it. Dropping them discards every pending version still recorded,
10
+ -- accepted ones included: a newer version then reaches the subscription as an
11
+ -- offer.
12
+ --
13
+ -- Run it the same way as the other files in this directory, before `db push`
14
+ -- where you use one:
15
+ --
16
+ -- psql "$DATABASE_URL" -f 1.0-a-newer-version-is-only-offered.postgres.sql
17
+ --
18
+ -- Run it once nothing reads the columns any more: no running version of the
19
+ -- application, and no job of your own — a notice or renewal job that set or
20
+ -- rolled forward a pending version — still uses them. A column's index and its
21
+ -- foreign key go with it.
22
+ --
23
+ -- Safe to run again, and on a database without a subscriptions table: every
24
+ -- column is dropped only where it exists.
25
+
26
+ ALTER TABLE IF EXISTS "subscriptions"
27
+ DROP COLUMN IF EXISTS "pendingPlanVersionId",
28
+ DROP COLUMN IF EXISTS "pendingPlanVersionEffectiveAt",
29
+ DROP COLUMN IF EXISTS "pendingPlanVersionAccepted",
30
+ DROP COLUMN IF EXISTS "pendingPlanVersionAcceptedAt",
31
+ DROP COLUMN IF EXISTS "pendingPlanVersionAcceptedByUserId",
32
+ DROP COLUMN IF EXISTS "pendingPlanVersionNotifiedAt",
33
+ DROP COLUMN IF EXISTS "pendingPlanVersionReminderSentAt";
@@ -0,0 +1,43 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a retirement is announced.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `version_retirements` keeps every retirement an operator
6
+ -- announces — a plan version ended for the subscriptions already on it, the
7
+ -- version they continue on, when and by whom. What it means for each
8
+ -- subscription is a `subscription_notices` row of kind `version-retired`, which
9
+ -- `1.0-a-subscriber-is-told-once.postgres.sql` creates.
10
+ --
11
+ -- Run it the same way as the other files in this directory, before `db push`
12
+ -- where you use one:
13
+ --
14
+ -- psql "$DATABASE_URL" -f 1.0-a-retirement-is-announced.postgres.sql
15
+ --
16
+ -- The record outlives the catalogue entries it names, so the versions are kept
17
+ -- as values and the table has no foreign key: nothing else has to exist for it
18
+ -- to be created.
19
+ --
20
+ -- Safe to run again: the table and its index are created only where they are
21
+ -- missing. On a database created from `reference-schema.postgres.sql` the whole
22
+ -- file does nothing at all.
23
+
24
+ BEGIN;
25
+
26
+ CREATE TABLE IF NOT EXISTS "version_retirements" (
27
+ "id" TEXT NOT NULL,
28
+ "retiredPlanVersionId" TEXT NOT NULL,
29
+ "retiredPlanKey" TEXT NOT NULL,
30
+ "retiredVersion" INTEGER NOT NULL,
31
+ "replacementPlanVersionId" TEXT NOT NULL,
32
+ "replacementPlanKey" TEXT NOT NULL,
33
+ "replacementVersion" INTEGER NOT NULL,
34
+ "announcedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
35
+ "announcedBy" TEXT NOT NULL,
36
+
37
+ CONSTRAINT "version_retirements_pkey" PRIMARY KEY ("id")
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS "version_retirements_retiredPlanVersionId_idx"
41
+ ON "version_retirements"("retiredPlanVersionId");
42
+
43
+ COMMIT;
@@ -0,0 +1,129 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a scheduled change to another plan binds the version it was
3
+ -- quoted at.
4
+ -- =============================================================================
5
+ --
6
+ -- `subscriptions` gains "pendingChangeVersionId": the version of "pendingPlan"
7
+ -- the plan-change preview showed when the change was scheduled. When the change
8
+ -- comes due, the platform binds that version rather than whichever is in effect
9
+ -- that day, so a version published in between does not reach the customer by a
10
+ -- change they already agreed to. Run it BEFORE `db push`, the same way as the
11
+ -- other files in this directory:
12
+ --
13
+ -- psql "$DATABASE_URL" -f 1.0-a-scheduled-change-keeps-its-quoted-version.postgres.sql
14
+ --
15
+ -- What happens to a change scheduled before this file. The version it was
16
+ -- shown was never recorded, so the one live and in effect now stands for it:
17
+ -- the newest published, not superseded, not ended, and — on a schema with
18
+ -- validity windows — already begun. A version published ahead of its date is
19
+ -- left out; the change then binds the version in effect when it comes due, as
20
+ -- it did before this file. A change that stays on its plan is left empty: it keeps the
21
+ -- version bound, whatever that is by the day it comes due. A plan with no live
22
+ -- version is left empty too, and binds the version in effect when it comes due.
23
+ --
24
+ -- The plan is found the way the Prisma adapter's `planBinding` stores it: by
25
+ -- its key in "plan_versions"."planId" (`legacy-plan-key`), or — where the
26
+ -- column holds the plan's row id (`normalized-plan-id`) — through the row of
27
+ -- "plans" that carries the key. The fragment generates that row id as a UUID,
28
+ -- so it does not coincide with a key and at most one of the two matches. The
29
+ -- second is left out where "plans" has no "planKey", which includes a schema
30
+ -- with no "plans" at all.
31
+ --
32
+ -- Safe to run again: the column, its index and its foreign key are added only
33
+ -- where they are missing, and the backfill runs only in the run that adds the
34
+ -- column. A second run therefore finds the column and changes nothing — it
35
+ -- does not pin a version published since the first run to a change scheduled
36
+ -- before it. On a database created from `reference-schema.postgres.sql` the
37
+ -- whole file does nothing: the column is already there.
38
+
39
+ BEGIN;
40
+
41
+ DO $$
42
+ BEGIN
43
+ IF to_regclass('subscriptions') IS NULL OR to_regclass('plan_versions') IS NULL THEN
44
+ RAISE NOTICE 'subscriptions or plan_versions is not present — nothing to migrate.';
45
+ RETURN; -- an installation that never adopted these fragments
46
+ END IF;
47
+
48
+ IF NOT EXISTS (
49
+ SELECT 1
50
+ FROM information_schema.columns
51
+ WHERE table_schema = current_schema()
52
+ AND table_name = 'subscriptions'
53
+ AND column_name = 'pendingChangeVersionId'
54
+ ) THEN
55
+ ALTER TABLE "subscriptions" ADD COLUMN "pendingChangeVersionId" TEXT;
56
+
57
+ -- Live and in effect: published, not superseded, and — where the
58
+ -- schema has the columns — not ended and already begun. An ended
59
+ -- version takes no new bookings, and one published ahead of its date
60
+ -- is not the one in effect. Each clause is added only where its column
61
+ -- exists; a schema from before it ends and delays nothing.
62
+ EXECUTE format(
63
+ $backfill$
64
+ UPDATE "subscriptions" AS s
65
+ SET "pendingChangeVersionId" = (
66
+ SELECT v."id"
67
+ FROM "plan_versions" AS v
68
+ WHERE (v."planId" = s."pendingPlan" %s)
69
+ AND v."publishedAt" IS NOT NULL
70
+ AND v."supersededAt" IS NULL
71
+ %s
72
+ %s
73
+ ORDER BY v."version" DESC
74
+ LIMIT 1
75
+ )
76
+ WHERE s."pendingPlan" IS NOT NULL
77
+ AND s."pendingPlan" <> s."plan"
78
+ $backfill$,
79
+ CASE
80
+ WHEN EXISTS (
81
+ SELECT 1
82
+ FROM information_schema.columns
83
+ WHERE table_schema = current_schema()
84
+ AND table_name = 'plans'
85
+ AND column_name = 'planKey'
86
+ )
87
+ THEN 'OR v."planId" IN (SELECT p."id" FROM "plans" AS p WHERE p."planKey" = s."pendingPlan")'
88
+ ELSE ''
89
+ END,
90
+ CASE
91
+ WHEN EXISTS (
92
+ SELECT 1
93
+ FROM information_schema.columns
94
+ WHERE table_schema = current_schema()
95
+ AND table_name = 'plan_versions'
96
+ AND column_name = 'endsAt'
97
+ )
98
+ THEN 'AND (v."endsAt" IS NULL OR v."endsAt" > NOW())'
99
+ ELSE ''
100
+ END,
101
+ CASE
102
+ WHEN EXISTS (
103
+ SELECT 1
104
+ FROM information_schema.columns
105
+ WHERE table_schema = current_schema()
106
+ AND table_name = 'plan_versions'
107
+ AND column_name = 'validFrom'
108
+ )
109
+ THEN 'AND (v."validFrom" IS NULL OR v."validFrom" <= NOW())'
110
+ ELSE ''
111
+ END
112
+ );
113
+ END IF;
114
+
115
+ IF NOT EXISTS (
116
+ SELECT 1 FROM pg_constraint WHERE conname = 'subscriptions_pendingChangeVersionId_fkey'
117
+ ) THEN
118
+ ALTER TABLE "subscriptions"
119
+ ADD CONSTRAINT "subscriptions_pendingChangeVersionId_fkey"
120
+ FOREIGN KEY ("pendingChangeVersionId") REFERENCES "plan_versions"("id")
121
+ ON DELETE RESTRICT ON UPDATE CASCADE;
122
+ END IF;
123
+
124
+ CREATE INDEX IF NOT EXISTS "subscriptions_pendingChangeVersionId_idx"
125
+ ON "subscriptions"("pendingChangeVersionId");
126
+ END
127
+ $$;
128
+
129
+ COMMIT;
@@ -0,0 +1,48 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a subscriber is told once.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `subscription_notices` keeps the record of every notice
6
+ -- a subscriber is due — that a newer version of their plan is offered to them —
7
+ -- with when it went out, to whom and how. The platform writes it where
8
+ -- `tenantBilling.versionNotices` is turned on, and the unique key is what makes
9
+ -- each notice once rather than once per run.
10
+ --
11
+ -- Run it the same way as the other files in this directory, before `db push`
12
+ -- where you use one:
13
+ --
14
+ -- psql "$DATABASE_URL" -f 1.0-a-subscriber-is-told-once.postgres.sql
15
+ --
16
+ -- The record outlives the subscription, as the subscriber's account does, so
17
+ -- the table keeps the tenant and the subscription as values and has no foreign
18
+ -- key: nothing else has to exist for it to be created.
19
+ --
20
+ -- Safe to run again: the table and both indexes are created only where they are
21
+ -- missing. On a database created from `reference-schema.postgres.sql` the whole
22
+ -- file does nothing at all.
23
+
24
+ BEGIN;
25
+
26
+ CREATE TABLE IF NOT EXISTS "subscription_notices" (
27
+ "id" TEXT NOT NULL,
28
+ "tenantId" TEXT NOT NULL,
29
+ "subscriptionId" TEXT NOT NULL,
30
+ "kind" TEXT NOT NULL,
31
+ "subject" TEXT NOT NULL,
32
+ "content" JSONB NOT NULL,
33
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34
+ "claimedAt" TIMESTAMP(3),
35
+ "deliveredAt" TIMESTAMP(3),
36
+ "recipients" JSONB,
37
+ "channel" TEXT,
38
+
39
+ CONSTRAINT "subscription_notices_pkey" PRIMARY KEY ("id")
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS "subscription_notices_kind_subject_idx"
43
+ ON "subscription_notices"("kind", "subject");
44
+
45
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscription_notices_subscriptionId_kind_subject_key"
46
+ ON "subscription_notices"("subscriptionId", "kind", "subject");
47
+
48
+ COMMIT;
@@ -19,6 +19,8 @@
19
19
  -- prisma-fragments/14-payments.prisma
20
20
  -- prisma-fragments/15-subscriber-ledger.prisma
21
21
  -- prisma-fragments/16-maintenance-window.prisma
22
+ -- prisma-fragments/17-subscription-notice.prisma
23
+ -- prisma-fragments/18-version-retirement.prisma
22
24
  -- plus the normative constraints from sql/constraints.postgres.sql.
23
25
  -- Do not edit by hand — change the fragments/constraints and regenerate.
24
26
 
@@ -68,18 +70,12 @@ CREATE TABLE "subscriptions" (
68
70
  "minimumTermUntil" TIMESTAMP(3),
69
71
  "billingAnchorDay" INTEGER,
70
72
  "planVersionId" TEXT NOT NULL,
71
- "pendingPlanVersionId" TEXT,
72
- "pendingPlanVersionEffectiveAt" TIMESTAMP(3),
73
- "pendingPlanVersionAccepted" BOOLEAN NOT NULL DEFAULT false,
74
- "pendingPlanVersionAcceptedAt" TIMESTAMP(3),
75
- "pendingPlanVersionAcceptedByUserId" TEXT,
76
- "pendingPlanVersionNotifiedAt" TIMESTAMP(3),
77
- "pendingPlanVersionReminderSentAt" TIMESTAMP(3),
78
73
  "trialEntitlementPlan" TEXT,
79
74
  "postTrialPlan" TEXT,
80
75
  "pendingPlan" TEXT,
81
76
  "pendingBillingCycle" "BillingCycle",
82
77
  "pendingEffectiveAt" TIMESTAMP(3),
78
+ "pendingChangeVersionId" TEXT,
83
79
  "customMonthlyNet" DECIMAL(10,2),
84
80
  "customLimits" JSONB,
85
81
  "customNote" TEXT,
@@ -723,6 +719,38 @@ CREATE TABLE "maintenance_windows" (
723
719
  CONSTRAINT "maintenance_windows_pkey" PRIMARY KEY ("id")
724
720
  );
725
721
 
722
+ -- CreateTable
723
+ CREATE TABLE "subscription_notices" (
724
+ "id" TEXT NOT NULL,
725
+ "tenantId" TEXT NOT NULL,
726
+ "subscriptionId" TEXT NOT NULL,
727
+ "kind" TEXT NOT NULL,
728
+ "subject" TEXT NOT NULL,
729
+ "content" JSONB NOT NULL,
730
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
731
+ "claimedAt" TIMESTAMP(3),
732
+ "deliveredAt" TIMESTAMP(3),
733
+ "recipients" JSONB,
734
+ "channel" TEXT,
735
+
736
+ CONSTRAINT "subscription_notices_pkey" PRIMARY KEY ("id")
737
+ );
738
+
739
+ -- CreateTable
740
+ CREATE TABLE "version_retirements" (
741
+ "id" TEXT NOT NULL,
742
+ "retiredPlanVersionId" TEXT NOT NULL,
743
+ "retiredPlanKey" TEXT NOT NULL,
744
+ "retiredVersion" INTEGER NOT NULL,
745
+ "replacementPlanVersionId" TEXT NOT NULL,
746
+ "replacementPlanKey" TEXT NOT NULL,
747
+ "replacementVersion" INTEGER NOT NULL,
748
+ "announcedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
749
+ "announcedBy" TEXT NOT NULL,
750
+
751
+ CONSTRAINT "version_retirements_pkey" PRIMARY KEY ("id")
752
+ );
753
+
726
754
  -- CreateIndex
727
755
  CREATE UNIQUE INDEX "subscriptions_tenantId_key" ON "subscriptions"("tenantId");
728
756
 
@@ -733,7 +761,7 @@ CREATE INDEX "subscriptions_tenantId_idx" ON "subscriptions"("tenantId");
733
761
  CREATE INDEX "subscriptions_planVersionId_idx" ON "subscriptions"("planVersionId");
734
762
 
735
763
  -- CreateIndex
736
- CREATE INDEX "subscriptions_pendingPlanVersionId_idx" ON "subscriptions"("pendingPlanVersionId");
764
+ CREATE INDEX "subscriptions_pendingChangeVersionId_idx" ON "subscriptions"("pendingChangeVersionId");
737
765
 
738
766
  -- CreateIndex
739
767
  CREATE INDEX "subscriptions_currentPeriodEnd_idx" ON "subscriptions"("currentPeriodEnd");
@@ -960,11 +988,20 @@ CREATE UNIQUE INDEX "subscriber_ledger_entries_subscriptionId_source_sourceRef_p
960
988
  -- CreateIndex
961
989
  CREATE INDEX "maintenance_windows_createdAt_idx" ON "maintenance_windows"("createdAt");
962
990
 
991
+ -- CreateIndex
992
+ CREATE INDEX "subscription_notices_kind_subject_idx" ON "subscription_notices"("kind", "subject");
993
+
994
+ -- CreateIndex
995
+ CREATE UNIQUE INDEX "subscription_notices_subscriptionId_kind_subject_key" ON "subscription_notices"("subscriptionId", "kind", "subject");
996
+
997
+ -- CreateIndex
998
+ CREATE INDEX "version_retirements_retiredPlanVersionId_idx" ON "version_retirements"("retiredPlanVersionId");
999
+
963
1000
  -- AddForeignKey
964
1001
  ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planVersionId_fkey" FOREIGN KEY ("planVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
965
1002
 
966
1003
  -- AddForeignKey
967
- ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_pendingPlanVersionId_fkey" FOREIGN KEY ("pendingPlanVersionId") REFERENCES "plan_versions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
1004
+ ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_pendingChangeVersionId_fkey" FOREIGN KEY ("pendingChangeVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
968
1005
 
969
1006
  -- AddForeignKey
970
1007
  ALTER TABLE "promo_code_redemptions" ADD CONSTRAINT "promo_code_redemptions_promoCodeId_fkey" FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;