@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.
Files changed (30) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +59 -0
  3. package/acceptance/README.md +62 -0
  4. package/acceptance/manifest/full-manifest-requires-super-admin.yaml +43 -0
  5. package/acceptance/manifest/public-boot-no-auth.yaml +40 -0
  6. package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +57 -0
  7. package/acceptance/plan-version/publish-does-not-touch-bestand.yaml +62 -0
  8. package/acceptance/promo/first-time-only-blocks-second-redemption.yaml +40 -0
  9. package/acceptance/tenant/suspend-creates-audit-and-blocks-login.yaml +69 -0
  10. package/admin-api.openapi.yaml +1724 -0
  11. package/cli-conventions.md +158 -0
  12. package/index.cjs +18 -0
  13. package/index.d.cts +13 -0
  14. package/index.d.ts +14 -0
  15. package/index.js +17 -0
  16. package/package.json +63 -0
  17. package/prisma-fragments/01-subscription.prisma +216 -0
  18. package/prisma-fragments/02-promo-code.prisma +145 -0
  19. package/prisma-fragments/03-plan-versions.prisma +94 -0
  20. package/prisma-fragments/04-audit-log.prisma +38 -0
  21. package/prisma-fragments/05-bundle-business-type.prisma +206 -0
  22. package/prisma-fragments/06-catalog-entries.prisma +279 -0
  23. package/prisma-fragments/07-promotion.prisma +65 -0
  24. package/prisma-fragments/08-subscription-contract.prisma +92 -0
  25. package/prisma-fragments/09-pending-registration.prisma +96 -0
  26. package/prisma-fragments/README.md +115 -0
  27. package/schemas/admin-manifest.schema.json +328 -0
  28. package/schemas/audit-event.schema.json +73 -0
  29. package/schemas/plan-catalog.schema.json +166 -0
  30. 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
+ }