@saasicat/spec 0.2.0
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/LICENSE +202 -0
- package/README.md +59 -0
- package/acceptance/README.md +62 -0
- package/acceptance/manifest/full-manifest-requires-super-admin.yaml +43 -0
- package/acceptance/manifest/public-boot-no-auth.yaml +40 -0
- package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +57 -0
- package/acceptance/plan-version/publish-does-not-touch-bestand.yaml +62 -0
- package/acceptance/promo/first-time-only-blocks-second-redemption.yaml +40 -0
- package/acceptance/tenant/suspend-creates-audit-and-blocks-login.yaml +69 -0
- package/admin-api.openapi.yaml +1724 -0
- package/cli-conventions.md +158 -0
- package/index.cjs +18 -0
- package/index.d.cts +13 -0
- package/index.d.ts +14 -0
- package/index.js +17 -0
- package/package.json +63 -0
- package/prisma-fragments/01-subscription.prisma +216 -0
- package/prisma-fragments/02-promo-code.prisma +145 -0
- package/prisma-fragments/03-plan-versions.prisma +94 -0
- package/prisma-fragments/04-audit-log.prisma +38 -0
- package/prisma-fragments/05-bundle-business-type.prisma +206 -0
- package/prisma-fragments/06-catalog-entries.prisma +279 -0
- package/prisma-fragments/07-promotion.prisma +65 -0
- package/prisma-fragments/08-subscription-contract.prisma +92 -0
- package/prisma-fragments/09-pending-registration.prisma +96 -0
- package/prisma-fragments/README.md +115 -0
- package/schemas/admin-manifest.schema.json +328 -0
- package/schemas/audit-event.schema.json +73 -0
- package/schemas/plan-catalog.schema.json +166 -0
- package/schemas/promo-code.schema.json +214 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// SaaS platform Prisma fragment: plan versioning
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
|
|
6
|
+
//
|
|
7
|
+
// Global catalog tables (no tenantId, no RLS). Write access only via the
|
|
8
|
+
// SUPER_ADMIN guard in the AdminController of the `@saasicat/nest`
|
|
9
|
+
// package.
|
|
10
|
+
//
|
|
11
|
+
// Per identity key (planId):
|
|
12
|
+
// - at most 1 draft (publishedAt IS NULL) — constraint via a partial unique
|
|
13
|
+
// index in the migration (the Prisma schema cannot express partial unique:
|
|
14
|
+
// add it in the SQL migration as
|
|
15
|
+
// `CREATE UNIQUE INDEX … WHERE published_at IS NULL`).
|
|
16
|
+
// - multiple published versions, monotonically incrementing `version`.
|
|
17
|
+
// - `supersededAt` marks "no longer actively marketed"; the version
|
|
18
|
+
// remains billing-valid for existing subscriptions (contractual plan guarantee).
|
|
19
|
+
|
|
20
|
+
// -----------------------------------------------------------------------------
|
|
21
|
+
// Plan — root identity of a plan (SPEC_V2 §11.1 M6).
|
|
22
|
+
// `planKey` is the business plan identity (STARTER / STANDARD / PROFESSIONAL),
|
|
23
|
+
// unique per `projectKey`. PlanVersion.planId points **softly** to
|
|
24
|
+
// `Plan.planKey` (no DB FK — the binding is enforced via service logic in M6
|
|
25
|
+
// so that the greenfield cutover can run without a destructive schema
|
|
26
|
+
// migration).
|
|
27
|
+
//
|
|
28
|
+
// Soft-delete via `deletedAt`. A deleted plan root hides its
|
|
29
|
+
// PlanVersions in the UI, but keeps them effective for existing subscriptions
|
|
30
|
+
// (contract protection P1) — analogous to Bundle/BusinessType.
|
|
31
|
+
// -----------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
model Plan {
|
|
34
|
+
id String @id @default(uuid())
|
|
35
|
+
projectKey String
|
|
36
|
+
planKey String
|
|
37
|
+
label String
|
|
38
|
+
description String?
|
|
39
|
+
icon String?
|
|
40
|
+
sortOrder Int @default(0)
|
|
41
|
+
|
|
42
|
+
createdAt DateTime @default(now())
|
|
43
|
+
updatedAt DateTime @updatedAt
|
|
44
|
+
deletedAt DateTime?
|
|
45
|
+
|
|
46
|
+
@@unique([projectKey, planKey])
|
|
47
|
+
@@index([projectKey, deletedAt])
|
|
48
|
+
@@map("plans")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// -----------------------------------------------------------------------------
|
|
52
|
+
// PlanVersion — exactly one lineage per `planId` (= plan key from plans.yaml).
|
|
53
|
+
// -----------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
model PlanVersion {
|
|
56
|
+
id String @id @default(uuid())
|
|
57
|
+
planId String // plan key from plans.yaml (`plans[].id`)
|
|
58
|
+
version Int
|
|
59
|
+
baseVersionId String? // predecessor the draft diffs against (nullable for v1)
|
|
60
|
+
|
|
61
|
+
// Snapshot of the plan definition at the time of publishing.
|
|
62
|
+
// `features` is the published feature set as FeatureKey[].
|
|
63
|
+
// `quotas` is a free-form JSON object; concrete quota keys come from
|
|
64
|
+
// plans.yaml (`quotaKeys` pattern). Example:
|
|
65
|
+
// { "users": 5, "vehicles": 100, "storageGb": 50 }
|
|
66
|
+
features Json
|
|
67
|
+
quotas Json
|
|
68
|
+
|
|
69
|
+
monthlyNet Decimal @db.Decimal(10, 2)
|
|
70
|
+
yearlyNet Decimal @db.Decimal(10, 2) // net total amount per year for the YEARLY cycle
|
|
71
|
+
marketed Boolean @default(true)
|
|
72
|
+
|
|
73
|
+
publishedAt DateTime?
|
|
74
|
+
supersededAt DateTime?
|
|
75
|
+
publishedChanges Json? // VersionChange[] — see @saasicat/types
|
|
76
|
+
changeNote String
|
|
77
|
+
nonRegressive Boolean @default(true)
|
|
78
|
+
|
|
79
|
+
createdByUserId String?
|
|
80
|
+
publishedByUserId String?
|
|
81
|
+
|
|
82
|
+
createdAt DateTime @default(now())
|
|
83
|
+
updatedAt DateTime @updatedAt
|
|
84
|
+
|
|
85
|
+
baseVersion PlanVersion? @relation("PlanVersionLineage", fields: [baseVersionId], references: [id])
|
|
86
|
+
derivedVersions PlanVersion[] @relation("PlanVersionLineage")
|
|
87
|
+
subscriptionsCurrent Subscription[] @relation("SubscriptionPlanVersion")
|
|
88
|
+
subscriptionsPending Subscription[] @relation("SubscriptionPendingPlanVersion")
|
|
89
|
+
|
|
90
|
+
@@unique([planId, version])
|
|
91
|
+
@@index([planId, supersededAt])
|
|
92
|
+
@@index([planId, publishedAt])
|
|
93
|
+
@@map("plan_versions")
|
|
94
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// SaaS-Platform Prisma fragment: audit log
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
|
|
6
|
+
//
|
|
7
|
+
// Generic audit log for tenant- and admin-side actions. Consumers
|
|
8
|
+
// can share the log or keep their own parallel logs — the
|
|
9
|
+
// `@saasicat/nest` package writes into this schema.
|
|
10
|
+
//
|
|
11
|
+
// `action` must follow SCREAMING_SNAKE_CASE (see
|
|
12
|
+
// `schemas/audit-event.schema.json` action pattern). Consumer-specific
|
|
13
|
+
// actions are allowed; platform actions are cataloged in the `AuditActionDef`
|
|
14
|
+
// list of the manifest contributions.
|
|
15
|
+
|
|
16
|
+
model AuditLog {
|
|
17
|
+
id String @id @default(uuid())
|
|
18
|
+
tenantId String? // null for platform-global actions (plan publish etc.)
|
|
19
|
+
userId String? // actor — null for system cron or pre-auth actions
|
|
20
|
+
entity String // e.g. "Subscription", "PromoCode", "PlanVersion"
|
|
21
|
+
entityId String
|
|
22
|
+
action String // SCREAMING_SNAKE_CASE; e.g. "PROMO_CODE_REDEEM"
|
|
23
|
+
changes Json? // { field: { old: …, new: … } }
|
|
24
|
+
ipAddress String?
|
|
25
|
+
userAgent String?
|
|
26
|
+
|
|
27
|
+
createdAt DateTime @default(now())
|
|
28
|
+
|
|
29
|
+
// Relations — the consumer must define `Tenant` and `User` and
|
|
30
|
+
// enable relations. Userless system actions leave userId null.
|
|
31
|
+
// tenant Tenant? @relation(fields: [tenantId], references: [id])
|
|
32
|
+
// user User? @relation("AuditLogUser", fields: [userId], references: [id])
|
|
33
|
+
|
|
34
|
+
@@index([tenantId, entity, entityId])
|
|
35
|
+
@@index([tenantId, createdAt])
|
|
36
|
+
@@index([entity, action, createdAt])
|
|
37
|
+
@@map("audit_logs")
|
|
38
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// SaaS platform Prisma fragment: Bundle + BusinessType (plan catalog extension)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// REFERENCE SNIPPET — see 03-plan-versions.prisma for versioning conventions.
|
|
6
|
+
//
|
|
7
|
+
// Global catalog tables (no tenantId, no RLS). Write access only via the
|
|
8
|
+
// SUPER_ADMIN guard in the AdminController of the `@saasicat/nest`
|
|
9
|
+
// package. Entries are assigned to the respective consumer app via
|
|
10
|
+
// `projectKey`.
|
|
11
|
+
//
|
|
12
|
+
// Versioning pattern identical to PlanVersion:
|
|
13
|
+
// - at most 1 draft (publishedAt IS NULL) per identity — constraint via a
|
|
14
|
+
// partial unique index in the migration.
|
|
15
|
+
// - multiple published versions, monotonically incremental `version`.
|
|
16
|
+
// - `supersededAt` marks "no longer actively marketed"; existing bookings
|
|
17
|
+
// stay on the committed version (contractual plan guarantee P1).
|
|
18
|
+
//
|
|
19
|
+
// Naming note: GESCHAEFTSTYP_SPEC §3.1 partly speaks of `BundleDefinition`
|
|
20
|
+
// (with suffix) and `BusinessType` (without) — that is asymmetric. SPEC_V2
|
|
21
|
+
// unified this to a consistent `Bundle` / `BusinessType` (each without the
|
|
22
|
+
// `Definition` suffix). This file follows the V2 convention.
|
|
23
|
+
|
|
24
|
+
// -----------------------------------------------------------------------------
|
|
25
|
+
// Bundle — reusable component of features + quotas + pricing.
|
|
26
|
+
//
|
|
27
|
+
// Tenants do **not** book bundles directly; they are a SuperAdmin maintenance
|
|
28
|
+
// shell that is referenced in BusinessType versions (m:n via
|
|
29
|
+
// BusinessTypeBundle). Root entity without content — the purchasable fields
|
|
30
|
+
// (features, quotas, pricing) live on BundleVersion.
|
|
31
|
+
// -----------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
model Bundle {
|
|
34
|
+
id String @id @default(uuid())
|
|
35
|
+
projectKey String // e.g. 'clubapp' | 'demoapp' | …
|
|
36
|
+
bundleKey String // SCREAMING_SNAKE_CASE, e.g. 'SPORT', 'ACCOUNTING', 'EVERSEND_PEPPOL'
|
|
37
|
+
label String
|
|
38
|
+
description String?
|
|
39
|
+
icon String?
|
|
40
|
+
sortOrder Int @default(0)
|
|
41
|
+
|
|
42
|
+
// Locale translations { "en": { label, description }, … }. SPEC_V2 §6.4.
|
|
43
|
+
i18n Json @default("{}")
|
|
44
|
+
|
|
45
|
+
createdAt DateTime @default(now())
|
|
46
|
+
updatedAt DateTime @updatedAt
|
|
47
|
+
deletedAt DateTime?
|
|
48
|
+
|
|
49
|
+
versions BundleVersion[]
|
|
50
|
+
|
|
51
|
+
@@unique([projectKey, bundleKey])
|
|
52
|
+
@@index([projectKey, deletedAt])
|
|
53
|
+
@@map("bundles")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -----------------------------------------------------------------------------
|
|
57
|
+
// BundleVersion — versioned composition (features, quotas, pricing).
|
|
58
|
+
// `features` is FeatureKey[] (references Discovery / FeatureCatalogEntry).
|
|
59
|
+
// `quotas` is Record<QuotaKey, number>; -1 = unlimited; missing key = 0.
|
|
60
|
+
// `compatibility` is BundleCompatibility (`businessTypeKeys?` /
|
|
61
|
+
// `planIds?` whitelists). Both empty = usable everywhere.
|
|
62
|
+
// `pricingOverrides` is Array<BundlePricingOverride> for context-dependent
|
|
63
|
+
// prices (see GESCHAEFTSTYP_SPEC §6.1, "most-specific wins").
|
|
64
|
+
// -----------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
model BundleVersion {
|
|
67
|
+
id String @id @default(uuid())
|
|
68
|
+
bundleId String
|
|
69
|
+
version Int
|
|
70
|
+
baseVersionId String? // predecessor; null for v1
|
|
71
|
+
|
|
72
|
+
features Json // FeatureKey[]
|
|
73
|
+
quotas Json @default("{}") // Record<QuotaKey, number>
|
|
74
|
+
compatibility Json @default("{}") // BundleCompatibility
|
|
75
|
+
pricingOverrides Json @default("[]") // BundlePricingOverride[]
|
|
76
|
+
|
|
77
|
+
monthlyNet Decimal? @db.Decimal(10, 2) // default price; null = only via override
|
|
78
|
+
yearlyNet Decimal? @db.Decimal(10, 2)
|
|
79
|
+
marketed Boolean @default(true)
|
|
80
|
+
|
|
81
|
+
publishedAt DateTime?
|
|
82
|
+
supersededAt DateTime?
|
|
83
|
+
publishedChanges Json? // VersionChange[] — diff to predecessor version
|
|
84
|
+
changeNote String @default("")
|
|
85
|
+
nonRegressive Boolean @default(true)
|
|
86
|
+
|
|
87
|
+
createdByUserId String?
|
|
88
|
+
publishedByUserId String?
|
|
89
|
+
|
|
90
|
+
createdAt DateTime @default(now())
|
|
91
|
+
updatedAt DateTime @updatedAt
|
|
92
|
+
|
|
93
|
+
bundle Bundle @relation(fields: [bundleId], references: [id], onDelete: Cascade)
|
|
94
|
+
baseVersion BundleVersion? @relation("BundleVersionLineage", fields: [baseVersionId], references: [id])
|
|
95
|
+
derivedVersions BundleVersion[] @relation("BundleVersionLineage")
|
|
96
|
+
businessTypeBundles BusinessTypeBundle[]
|
|
97
|
+
|
|
98
|
+
@@unique([bundleId, version])
|
|
99
|
+
@@index([bundleId, supersededAt])
|
|
100
|
+
@@index([bundleId, publishedAt])
|
|
101
|
+
@@map("bundle_versions")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -----------------------------------------------------------------------------
|
|
105
|
+
// BusinessType — business vertical (club type, industry variant).
|
|
106
|
+
//
|
|
107
|
+
// Examples for a club app: SPORT_VEREIN, MOSCHEE_GEMEINDE,
|
|
108
|
+
// KIRCHEN_GEMEINDE, SOZIAL_TRAEGER. Example for a commerce app: AUTO_HANDEL.
|
|
109
|
+
// At **most one** BusinessType is referenced per subscription (via the
|
|
110
|
+
// associated BusinessTypeVersion). Multiple bundles at once = a dedicated
|
|
111
|
+
// BusinessType that composes the bundles internally.
|
|
112
|
+
// -----------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
model BusinessType {
|
|
115
|
+
id String @id @default(uuid())
|
|
116
|
+
projectKey String
|
|
117
|
+
businessTypeKey String // SCREAMING_SNAKE_CASE
|
|
118
|
+
label String
|
|
119
|
+
description String?
|
|
120
|
+
icon String?
|
|
121
|
+
sortOrder Int @default(0)
|
|
122
|
+
|
|
123
|
+
createdAt DateTime @default(now())
|
|
124
|
+
updatedAt DateTime @updatedAt
|
|
125
|
+
deletedAt DateTime?
|
|
126
|
+
|
|
127
|
+
versions BusinessTypeVersion[]
|
|
128
|
+
|
|
129
|
+
@@unique([projectKey, businessTypeKey])
|
|
130
|
+
@@index([projectKey, deletedAt])
|
|
131
|
+
@@map("business_types")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// -----------------------------------------------------------------------------
|
|
135
|
+
// BusinessTypeVersion — versioned composition of referenced bundles.
|
|
136
|
+
// `quotaOverrides` is Partial<Record<QuotaKey, number>>; missing key →
|
|
137
|
+
// Σ(bundle quotas), set key → replaces the sum (-1 = unlimited).
|
|
138
|
+
// `monthlyNet` null → effective price = Σ(bundle prices). Set → override.
|
|
139
|
+
// -----------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
model BusinessTypeVersion {
|
|
142
|
+
id String @id @default(uuid())
|
|
143
|
+
businessTypeId String
|
|
144
|
+
version Int
|
|
145
|
+
baseVersionId String?
|
|
146
|
+
|
|
147
|
+
quotaOverrides Json @default("{}") // Partial<Record<QuotaKey, number>>
|
|
148
|
+
|
|
149
|
+
monthlyNet Decimal? @db.Decimal(10, 2) // null = Σ(bundle prices)
|
|
150
|
+
yearlyNet Decimal? @db.Decimal(10, 2)
|
|
151
|
+
marketed Boolean @default(true)
|
|
152
|
+
|
|
153
|
+
publishedAt DateTime?
|
|
154
|
+
supersededAt DateTime?
|
|
155
|
+
publishedChanges Json?
|
|
156
|
+
changeNote String @default("")
|
|
157
|
+
nonRegressive Boolean @default(true)
|
|
158
|
+
|
|
159
|
+
createdByUserId String?
|
|
160
|
+
publishedByUserId String?
|
|
161
|
+
|
|
162
|
+
createdAt DateTime @default(now())
|
|
163
|
+
updatedAt DateTime @updatedAt
|
|
164
|
+
|
|
165
|
+
businessType BusinessType @relation(fields: [businessTypeId], references: [id], onDelete: Cascade)
|
|
166
|
+
baseVersion BusinessTypeVersion? @relation("BusinessTypeVersionLineage", fields: [baseVersionId], references: [id])
|
|
167
|
+
derivedVersions BusinessTypeVersion[] @relation("BusinessTypeVersionLineage")
|
|
168
|
+
bundles BusinessTypeBundle[]
|
|
169
|
+
|
|
170
|
+
// Consumers add the Subscription relation in their own schema.prisma.
|
|
171
|
+
// Example (commented out):
|
|
172
|
+
//
|
|
173
|
+
// subscriptionsCurrent Subscription[] @relation("SubscriptionBusinessTypeVersion")
|
|
174
|
+
//
|
|
175
|
+
// Prerequisite: in M5 Subscription gets the field
|
|
176
|
+
// `businessTypeVersionId String?` plus a CHECK constraint
|
|
177
|
+
// "at least one of planVersionId / businessTypeVersionId set".
|
|
178
|
+
|
|
179
|
+
@@unique([businessTypeId, version])
|
|
180
|
+
@@index([businessTypeId, supersededAt])
|
|
181
|
+
@@index([businessTypeId, publishedAt])
|
|
182
|
+
@@map("business_type_versions")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------------
|
|
186
|
+
// BusinessTypeBundle — m:n junction between BusinessTypeVersion and
|
|
187
|
+
// BundleVersion. Stores the *concrete* BundleVersion (not just the root), so
|
|
188
|
+
// that a published BusinessType stays deterministic — even when the bundle
|
|
189
|
+
// later gets a newer version.
|
|
190
|
+
// onDelete: Restrict for bundleVersion prevents deleting a BundleVersion that
|
|
191
|
+
// is still referenced in an active BusinessTypeVersion (additionally a hard
|
|
192
|
+
// block in the publish strict check).
|
|
193
|
+
// -----------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
model BusinessTypeBundle {
|
|
196
|
+
businessTypeVersionId String
|
|
197
|
+
bundleVersionId String
|
|
198
|
+
sortOrder Int @default(0)
|
|
199
|
+
|
|
200
|
+
businessTypeVersion BusinessTypeVersion @relation(fields: [businessTypeVersionId], references: [id], onDelete: Cascade)
|
|
201
|
+
bundleVersion BundleVersion @relation(fields: [bundleVersionId], references: [id], onDelete: Restrict)
|
|
202
|
+
|
|
203
|
+
@@id([businessTypeVersionId, bundleVersionId])
|
|
204
|
+
@@index([bundleVersionId])
|
|
205
|
+
@@map("business_type_bundles")
|
|
206
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// SaaS-Platform Prisma fragment: catalog entries (discovery review + marketing)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// REFERENCE SNIPPET — see 03-plan-versions.prisma for conventions.
|
|
6
|
+
//
|
|
7
|
+
// Three tables:
|
|
8
|
+
// - CapabilityCatalogEntry — SuperAdmin review status per code-declared
|
|
9
|
+
// capability (discovery projection)
|
|
10
|
+
// - FeatureCatalogEntry — ditto for features + marketing fields
|
|
11
|
+
// - MarketingProjection — marketing texts/pricing/highlights per
|
|
12
|
+
// plan/bundle/business-type version (locale pivot)
|
|
13
|
+
//
|
|
14
|
+
// Approval lifecycle (#20, feature-/quota-centric):
|
|
15
|
+
// pending → approved (↔ revoke) · outdated (drift) · obsolete
|
|
16
|
+
// Approval happens per FEATURE/QUOTA — capabilities now only carry the
|
|
17
|
+
// read-only code status (active | experimental | deprecated | retired).
|
|
18
|
+
//
|
|
19
|
+
// Backfill when migrating from the old vocabulary (per consumer DB):
|
|
20
|
+
// feature_/quota_catalog_entries.discoveryStatus:
|
|
21
|
+
// discovered→pending · accepted→approved · active→approved ·
|
|
22
|
+
// ignored→pending · deprecated→outdated · retired→obsolete
|
|
23
|
+
// capability_catalog_entries: rename column discoveryStatus → codeStatus,
|
|
24
|
+
// values: deprecated→deprecated · retired→retired · everything else→active
|
|
25
|
+
//
|
|
26
|
+
// Global catalog tables (no tenantId, no RLS). Write access only via the
|
|
27
|
+
// SUPER_ADMIN guard in the AdminController.
|
|
28
|
+
|
|
29
|
+
// -----------------------------------------------------------------------------
|
|
30
|
+
// CapabilityCatalogEntry — read-only code fact per code capability (#20).
|
|
31
|
+
//
|
|
32
|
+
// Every capability declared via @ImplementsCapability(...) appears here after
|
|
33
|
+
// the first discovery scan with its decorator status (`codeStatus`). The sync
|
|
34
|
+
// always overwrites the code status; it sets `retired` when the capability is
|
|
35
|
+
// missing from the new snapshot. There is no longer a business approval at the
|
|
36
|
+
// capability level — that lives on the feature/quota.
|
|
37
|
+
//
|
|
38
|
+
// `featureKey` and `bundleKey` are the aggregation wrappers declared in the
|
|
39
|
+
// decorator — denormalized for fast lookup in the discovery UI; the binding
|
|
40
|
+
// source, however, remains the code annotation, not this DB entry.
|
|
41
|
+
// -----------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
model CapabilityCatalogEntry {
|
|
44
|
+
id String @id @default(uuid())
|
|
45
|
+
projectKey String // e.g. 'clubapp' | 'demoapp' | …
|
|
46
|
+
capabilityKey String // 'invoice.create' | 'member.archive' | …
|
|
47
|
+
label String
|
|
48
|
+
description String?
|
|
49
|
+
|
|
50
|
+
// Aggregation wrappers from the decorator (denormalized).
|
|
51
|
+
featureKey String?
|
|
52
|
+
bundleKey String?
|
|
53
|
+
|
|
54
|
+
// Read-only code status from the decorator (#20); 'retired' = removed from code.
|
|
55
|
+
codeStatus String @default("active") // active | experimental | deprecated | retired
|
|
56
|
+
|
|
57
|
+
// Code owner tag for audit (e.g. 'accounting', 'membership').
|
|
58
|
+
owner String?
|
|
59
|
+
|
|
60
|
+
// Implementation kind (matches decorator.kind):
|
|
61
|
+
// 'endpoint' | 'service' | 'job' | 'event'
|
|
62
|
+
kind String
|
|
63
|
+
|
|
64
|
+
// When deprecated: target version + planned removal.
|
|
65
|
+
replacementKey String?
|
|
66
|
+
deprecatedAt DateTime?
|
|
67
|
+
removalPlannedAt DateTime?
|
|
68
|
+
reason String?
|
|
69
|
+
|
|
70
|
+
// Locale translations: { "en": { label, description }, "tr": {…} }.
|
|
71
|
+
// The default locale is deliberately absent — fallback in the UI. SPEC_V2 §6.3.
|
|
72
|
+
i18n Json @default("{}")
|
|
73
|
+
|
|
74
|
+
sortOrder Int @default(0)
|
|
75
|
+
|
|
76
|
+
createdAt DateTime @default(now())
|
|
77
|
+
updatedAt DateTime @updatedAt
|
|
78
|
+
deletedAt DateTime?
|
|
79
|
+
|
|
80
|
+
@@unique([projectKey, capabilityKey])
|
|
81
|
+
@@index([projectKey, codeStatus])
|
|
82
|
+
@@index([projectKey, featureKey])
|
|
83
|
+
@@map("capability_catalog_entries")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -----------------------------------------------------------------------------
|
|
87
|
+
// FeatureCatalogEntry — SuperAdmin review status + marketing master data per
|
|
88
|
+
// feature. Aggregation from the capabilities that declare `feature: 'XYZ'`
|
|
89
|
+
// in the decorator.
|
|
90
|
+
//
|
|
91
|
+
// Marketing texts (`marketingLabel` / `marketingDescription`) are the short
|
|
92
|
+
// slot for the sidebar/comparison matrix; the *long* locale-specific texts
|
|
93
|
+
// live in MarketingProjection (separate table, locale pivot).
|
|
94
|
+
//
|
|
95
|
+
// `plannedOnly = true` lets the SuperAdmin add a feature that does not yet
|
|
96
|
+
// exist in code to a plan (e.g. for roadmap marketing). The blocking strict-
|
|
97
|
+
// mode check (SPEC_V2 §8.1) rejects `plannedOnly` features at plan publish —
|
|
98
|
+
// they may only appear in internal drafts or warn-only builds.
|
|
99
|
+
// -----------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
model FeatureCatalogEntry {
|
|
102
|
+
id String @id @default(uuid())
|
|
103
|
+
projectKey String
|
|
104
|
+
featureKey String // 'INVOICE_MANAGEMENT' | 'MEMBER_LIST' | …
|
|
105
|
+
label String
|
|
106
|
+
description String?
|
|
107
|
+
|
|
108
|
+
// Marketing short form (for sidebar/comparison). Detailed texts
|
|
109
|
+
// per locale → MarketingProjection.
|
|
110
|
+
marketingLabel String?
|
|
111
|
+
marketingDescription String?
|
|
112
|
+
icon String?
|
|
113
|
+
|
|
114
|
+
// Tier hint for comparison-matrix sorting.
|
|
115
|
+
// Convention: 'CORE' < 'ADVANCED' < 'PRO' < 'ENTERPRISE'
|
|
116
|
+
tier String?
|
|
117
|
+
|
|
118
|
+
// Approval lifecycle (#20): pending | approved | outdated | obsolete.
|
|
119
|
+
discoveryStatus String @default("pending")
|
|
120
|
+
|
|
121
|
+
// Approval signature (#20): freezes the capability set at approval time
|
|
122
|
+
// ('capabilityKey@codeStatus', sorted, '|'-separated). The auto-sync
|
|
123
|
+
// compares against the current snapshot → on drift approved → outdated.
|
|
124
|
+
approvedAt DateTime?
|
|
125
|
+
approvedBy String?
|
|
126
|
+
approvedSignature String?
|
|
127
|
+
|
|
128
|
+
// True = feature is planned in the SuperAdmin but not yet implemented in
|
|
129
|
+
// code. Blocking strict mode rejects plan publish with plannedOnly
|
|
130
|
+
// features.
|
|
131
|
+
plannedOnly Boolean @default(false)
|
|
132
|
+
|
|
133
|
+
// Locale translations { "en": { label, description }, … }. SPEC_V2 §6.3.
|
|
134
|
+
i18n Json @default("{}")
|
|
135
|
+
|
|
136
|
+
sortOrder Int @default(0)
|
|
137
|
+
|
|
138
|
+
createdAt DateTime @default(now())
|
|
139
|
+
updatedAt DateTime @updatedAt
|
|
140
|
+
deletedAt DateTime?
|
|
141
|
+
|
|
142
|
+
@@unique([projectKey, featureKey])
|
|
143
|
+
@@index([projectKey, discoveryStatus])
|
|
144
|
+
@@index([projectKey, plannedOnly])
|
|
145
|
+
@@map("feature_catalog_entries")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -----------------------------------------------------------------------------
|
|
149
|
+
// QuotaCatalogEntry — SuperAdmin review status per code-declared quota
|
|
150
|
+
// (`@DefinesQuota`). `usageProvider` is the class that provides the quota;
|
|
151
|
+
// `null` = referenced (`@EnforceQuota`) but provided by no class. A hard quota
|
|
152
|
+
// without `usageProvider` is not deployable — `yada app preflight` blocks it
|
|
153
|
+
// (SPEC_V2 §6.3 + §8.3).
|
|
154
|
+
// -----------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
model QuotaCatalogEntry {
|
|
157
|
+
id String @id @default(uuid())
|
|
158
|
+
projectKey String
|
|
159
|
+
quotaKey String // 'invoicesPerMonth' | 'members' | 'storageGb' | …
|
|
160
|
+
label String
|
|
161
|
+
description String?
|
|
162
|
+
|
|
163
|
+
unit String // Display unit: 'members' | 'GB' | '/month' | …
|
|
164
|
+
featureKey String? // Aggregation wrapper from the decorator (denormalized)
|
|
165
|
+
|
|
166
|
+
// Class that declares the quota via @DefinesQuota (= UsageProvider).
|
|
167
|
+
usageProvider String?
|
|
168
|
+
enforcementMode String @default("soft") // hard | soft
|
|
169
|
+
|
|
170
|
+
// Approval lifecycle (#20): pending | approved | outdated | obsolete.
|
|
171
|
+
discoveryStatus String @default("pending")
|
|
172
|
+
|
|
173
|
+
// Approval signature (#20): code-derived quota facts at approval time
|
|
174
|
+
// ('unit|enforcementMode|usageProvider|featureKey'); on drift approved →
|
|
175
|
+
// outdated.
|
|
176
|
+
approvedAt DateTime?
|
|
177
|
+
approvedBy String?
|
|
178
|
+
approvedSignature String?
|
|
179
|
+
|
|
180
|
+
// Locale translations { "en": { label, unit, description }, … }.
|
|
181
|
+
i18n Json @default("{}")
|
|
182
|
+
|
|
183
|
+
sortOrder Int @default(0)
|
|
184
|
+
|
|
185
|
+
createdAt DateTime @default(now())
|
|
186
|
+
updatedAt DateTime @updatedAt
|
|
187
|
+
deletedAt DateTime?
|
|
188
|
+
|
|
189
|
+
@@unique([projectKey, quotaKey])
|
|
190
|
+
@@index([projectKey, discoveryStatus])
|
|
191
|
+
@@map("quota_catalog_entries")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -----------------------------------------------------------------------------
|
|
195
|
+
// MarketingProjection — locale-specific marketing texts per
|
|
196
|
+
// plan/bundle/business-type version.
|
|
197
|
+
//
|
|
198
|
+
// Read and projected by the public catalog controller
|
|
199
|
+
// (`GET /public/catalog?locale=de`). Only versions with status `marketed` and
|
|
200
|
+
// a matching MarketingProjection entry appear in the public list.
|
|
201
|
+
//
|
|
202
|
+
// Polymorphic reference via (`targetType`, `targetVersionId`) instead of three
|
|
203
|
+
// separate FK columns — deliberate, because a cascade delete for three models
|
|
204
|
+
// would be complicated and uniqueness via (targetType, targetVersionId,
|
|
205
|
+
// locale) is sufficient. App logic checks existence on read.
|
|
206
|
+
//
|
|
207
|
+
// MVP: locale `de` is mandatory; further locales (e.g. `en`) are allowed as
|
|
208
|
+
// schema preparation but not enforced (see SPEC_V2 V2-Q1).
|
|
209
|
+
// -----------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
model MarketingProjection {
|
|
212
|
+
id String @id @default(uuid())
|
|
213
|
+
projectKey String
|
|
214
|
+
|
|
215
|
+
// Polymorphic reference to a versioned entity.
|
|
216
|
+
targetType String // 'PLAN' | 'BUNDLE' | 'BUSINESS_TYPE'
|
|
217
|
+
targetVersionId String // PlanVersion.id | BundleVersion.id | BusinessTypeVersion.id
|
|
218
|
+
|
|
219
|
+
locale String @default("de") // ISO-639-1, optionally with a region suffix ('de', 'en', 'de-AT')
|
|
220
|
+
|
|
221
|
+
displayLabel String
|
|
222
|
+
description String
|
|
223
|
+
|
|
224
|
+
// Visibility in the public catalog — false = projection exists but does
|
|
225
|
+
// not appear on the pricing page.
|
|
226
|
+
visible Boolean @default(true)
|
|
227
|
+
|
|
228
|
+
// Optional badge at the top of the card ('Beliebt', 'Neu'). '' = no badge.
|
|
229
|
+
badge String @default("")
|
|
230
|
+
|
|
231
|
+
// Top features for the public catalog card: [{ label, strong }].
|
|
232
|
+
// Order is the display order.
|
|
233
|
+
topFeatures Json @default("[]") // { label: string; strong: string }[]
|
|
234
|
+
|
|
235
|
+
// Free trial period — controls the auto-generated CTA text.
|
|
236
|
+
trialEnabled Boolean @default(false)
|
|
237
|
+
trialDays Int @default(30)
|
|
238
|
+
|
|
239
|
+
// Optional formatted pricing tag (e.g. "€ 9,90 / Monat" or "auf Anfrage").
|
|
240
|
+
// If null: pricing is formatted automatically from PlanVersion.monthlyNet
|
|
241
|
+
// etc. at public-catalog render time.
|
|
242
|
+
priceTag String?
|
|
243
|
+
|
|
244
|
+
// Overrides the auto-generated call-to-action text.
|
|
245
|
+
ctaLabel String?
|
|
246
|
+
|
|
247
|
+
// Sort order in the public list (DESC). Higher values first.
|
|
248
|
+
priority Int @default(0)
|
|
249
|
+
|
|
250
|
+
// "Empfohlen" star / featured highlight in the UI.
|
|
251
|
+
highlight Boolean @default(false)
|
|
252
|
+
|
|
253
|
+
createdAt DateTime @default(now())
|
|
254
|
+
updatedAt DateTime @updatedAt
|
|
255
|
+
|
|
256
|
+
@@unique([targetType, targetVersionId, locale])
|
|
257
|
+
@@index([projectKey, targetType, locale, priority])
|
|
258
|
+
@@map("marketing_projections")
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// -----------------------------------------------------------------------------
|
|
262
|
+
// MarketingSettings — project-wide, runtime-editable marketing config.
|
|
263
|
+
// One row per project (`projectKey` unique). Currently exactly one field:
|
|
264
|
+
// `activeLocales` — the subset of the `availableLocales` pool activated in the
|
|
265
|
+
// marketing catalog (the pool comes from the app config, SPEC_V2 §6.5).
|
|
266
|
+
// If the row is missing, the full pool counts as active.
|
|
267
|
+
// -----------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
model MarketingSettings {
|
|
270
|
+
id String @id @default(uuid())
|
|
271
|
+
projectKey String @unique
|
|
272
|
+
|
|
273
|
+
activeLocales Json @default("[]") // string[]
|
|
274
|
+
|
|
275
|
+
createdAt DateTime @default(now())
|
|
276
|
+
updatedAt DateTime @updatedAt
|
|
277
|
+
|
|
278
|
+
@@map("marketing_settings")
|
|
279
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// SaaS-Platform Prisma fragment: Promotion (time-scheduled pricing campaigns)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// REFERENCE SNIPPET — see 03-plan-versions.prisma for conventions.
|
|
6
|
+
//
|
|
7
|
+
// A Promotion is a catalog-side, time-limited pricing campaign.
|
|
8
|
+
// It overrides the pricing-page price automatically (without entering a code,
|
|
9
|
+
// unless `requiresCoupon`). Separate from `PromoCode` (a redeemable
|
|
10
|
+
// checkout voucher, 02-promo-code.prisma) — `requiresCoupon = true`
|
|
11
|
+
// couples a Promotion to existing PromoCode codes via `codes`.
|
|
12
|
+
//
|
|
13
|
+
// Global catalog table (no tenantId, no RLS). Write access only
|
|
14
|
+
// via the SUPER_ADMIN guard in the AdminController.
|
|
15
|
+
|
|
16
|
+
model Promotion {
|
|
17
|
+
id String @id @default(uuid())
|
|
18
|
+
projectKey String // e.g. 'clubapp' | 'demoapp' | …
|
|
19
|
+
|
|
20
|
+
// Internal label (not public).
|
|
21
|
+
internalLabel String
|
|
22
|
+
|
|
23
|
+
// Campaign type: percent | amount | intro | freeMonths
|
|
24
|
+
type String
|
|
25
|
+
|
|
26
|
+
// Type-dependent value:
|
|
27
|
+
// percent/amount → number
|
|
28
|
+
// intro → { price, months }
|
|
29
|
+
// freeMonths → number
|
|
30
|
+
value Json
|
|
31
|
+
|
|
32
|
+
// Target type of the keys in `appliesTo`: PLAN | BUNDLE | OFFER.
|
|
33
|
+
// Null/missing in legacy records is interpreted as PLAN.
|
|
34
|
+
targetType String @default("PLAN")
|
|
35
|
+
|
|
36
|
+
// Target keys the campaign applies to.
|
|
37
|
+
appliesTo Json @default("[]") // string[]
|
|
38
|
+
|
|
39
|
+
// Billing cycle: monthly | yearly | both
|
|
40
|
+
billingCycle String @default("both")
|
|
41
|
+
|
|
42
|
+
validFrom DateTime
|
|
43
|
+
validTo DateTime
|
|
44
|
+
|
|
45
|
+
// On overlap, the highest value wins.
|
|
46
|
+
priority Int @default(0)
|
|
47
|
+
|
|
48
|
+
// Language restriction; null = all locales.
|
|
49
|
+
onlyLocales Json? // string[] | null
|
|
50
|
+
|
|
51
|
+
requiresCoupon Boolean @default(false)
|
|
52
|
+
codes Json @default("[]") // string[] — referenced PromoCode codes
|
|
53
|
+
|
|
54
|
+
// UI accent color (timeline/ribbon).
|
|
55
|
+
color String @default("#2563eb")
|
|
56
|
+
|
|
57
|
+
// Locale texts { "de": { badge, fineprint }, … }.
|
|
58
|
+
i18n Json @default("{}")
|
|
59
|
+
|
|
60
|
+
createdAt DateTime @default(now())
|
|
61
|
+
updatedAt DateTime @updatedAt
|
|
62
|
+
|
|
63
|
+
@@index([projectKey, targetType, validFrom, validTo])
|
|
64
|
+
@@map("promotions")
|
|
65
|
+
}
|