@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.
- package/acceptance/README.md +1 -2
- package/acceptance/plan-version/publish-does-not-touch-existing-contracts.yaml +2 -9
- package/admin-api.openapi.yaml +215 -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/17-subscription-notice.prisma +55 -0
- package/prisma-fragments/18-version-retirement.prisma +44 -0
- package/prisma-fragments/README.md +1 -0
- 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/reference-schema.postgres.sql +46 -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,
|
|
@@ -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:
|
|
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
|
@@ -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?
|
|
@@ -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>'
|
|
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 "
|
|
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 "
|
|
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;
|