@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,158 @@
1
+ ---
2
+ doc_title: SaaS Platform CLI Conventions
3
+ status: spec
4
+ date: 2026-05-08
5
+ related:
6
+ - admin-api.openapi.yaml
7
+ - schemas/audit-event.schema.json
8
+ ---
9
+
10
+ # CLI Conventions
11
+
12
+ Binding conventions for every consumer-specific CLI that serves the
13
+ SaaS platform services (each app picks its own binary name,
14
+ e.g. `myapp`). Embedded platform commands (`@saasicat/cli`) follow
15
+ the same rules; consumer-specific plugin commands are required to inherit
16
+ them.
17
+
18
+ ## 1. Identity
19
+
20
+ Every writing CLI invocation needs a **unique actor identity**
21
+ for the audit log:
22
+
23
+ - **Env var** with an app-specific prefix (e.g. `MYAPP_ADMIN_EMAIL`)
24
+ or the generic `SAAS_ADMIN_EMAIL` as the platform default.
25
+ - **Flag** `--as <email>` overrides the env var ad hoc (e.g. when
26
+ several SUPER_ADMINs share the same shell).
27
+ - Without an identity → writing commands are rejected with exit code `2`
28
+ (see §6).
29
+
30
+ Read commands (e.g. `… mandant list`) are allowed without an identity, but
31
+ write `actor=anonymous` to the local log.
32
+
33
+ ## 2. Mandatory MFA for Critical Operations
34
+
35
+ The following operations **MUST** prompt for a TOTP code
36
+ (Google Authenticator) before execution:
37
+
38
+ - `… paket apply` (PlanCatalog mutation)
39
+ - `… pilot create|grant|revoke` (pilot override)
40
+ - `… mandant suspend|impersonate` (tenant security operations)
41
+ - `… plan-version publish` (PlanVersion publication)
42
+ - `… user reassign-admin` (last-admin escalation)
43
+ - `… admin mfa-reset` (MFA reset of another SUPER_ADMIN)
44
+
45
+ Setup via `… admin mfa-setup` (generates a secret, shows a QR code, persists
46
+ `MfaPort.setSecret`). Without MFA setup → the first critical invocation forces
47
+ setup. Wrong code → exit code `3`.
48
+
49
+ `isMfaSetupRequired` and `verifyMfaCode` are passed through via the platform
50
+ `MfaPort` interface (see `@saasicat/types/ports.types.ts`)
51
+ — consumers implement the persistence.
52
+
53
+ ## 3. Production Confirm
54
+
55
+ When the CLI runs against a **production environment** (default: anything that
56
+ is not `NODE_ENV=development` and not a localhost DB), every
57
+ writing command must be confirmed interactively:
58
+
59
+ ```text
60
+ ? Tippe production zur Bestätigung: production
61
+ ```
62
+
63
+ - Alternative: `--yes` / `-y` skips the confirmation (for CI/CD).
64
+ - Plus `--dry-run` is the default for destructive commands like
65
+ `… paket apply` and `… rabatt delete`. Only `--apply` or
66
+ `--yes` applies.
67
+
68
+ `production` detection should run through the consumer's implementation of the
69
+ `isProductionEnvironment()` helper function (the consumer checks the
70
+ `DATABASE_URL` host, `process.env.APP_ENV`, etc.).
71
+
72
+ ## 4. Output Formats
73
+
74
+ Every command supports three output formats via `--output / -o`:
75
+
76
+ - `--output=table` (default) — human-readable ASCII table.
77
+ - `--output=json` — structured JSON (for `jq` pipelines, CI scripts).
78
+ - `--output=plain` — one line per record, tab-separated (for
79
+ `awk`/`cut` pipelines).
80
+
81
+ Error output goes to `stderr`, data output to `stdout` — pipe-stable.
82
+
83
+ ## 5. Mandatory Audit
84
+
85
+ Every writing operation **MUST** create an `AuditLog` entry with the following
86
+ required fields (see `schemas/audit-event.schema.json`):
87
+
88
+ - `action` — SCREAMING_SNAKE_CASE (`PROMO_CODE_CREATED`, `PILOT_GRANTED`,
89
+ `PLAN_CATALOG_UPDATE`, …).
90
+ - `entity` + `entityId` — the changed object.
91
+ - `userId` — resolvable from `--as` / env var via `UserPort.findByEmail`.
92
+ - `changes` — before/after diff for mutations (Ajv-validated via the
93
+ schema).
94
+
95
+ Consumers may define their own `action` values, but must keep the
96
+ SCREAMING_SNAKE_CASE pattern and declare them in the audit catalog of the
97
+ respective ManifestContribution.
98
+
99
+ ## 6. Exit Codes
100
+
101
+ Binding exit codes — consumer CLIs **must not**
102
+ redefine them, because cron/CI scripts pattern-match on them:
103
+
104
+ | Code | Meaning |
105
+ | ---- | ------------------------------------------------------------------------------------------------------------------------------------ |
106
+ | 0 | Success (including dry-run with no changes) |
107
+ | 1 | User error (wrong argument, missing required field, validation error) |
108
+ | 2 | Identity/auth error (no email, invalid user, not a SUPER_ADMIN) |
109
+ | 3 | MFA error (wrong TOTP code, MFA setup missing) |
110
+ | 4 | Connectivity error (DB unreachable, sidecar service down) |
111
+ | 5 | Permission error (user is not allowed to perform this operation — e.g. a SUPER_ADMIN operation, but the user is only a TENANT_ADMIN) |
112
+ | 6 | Conflict (optimistic-lock mismatch, idempotency violation) |
113
+ | 7 | Drift detected (e.g. `paket diff` finds differences; `manifest check` finds inconsistencies) |
114
+ | 99 | Internal error (uncaught exception, bug reports welcome) |
115
+
116
+ Read commands return `0` even for an empty result set (no drift =
117
+ no error). Drift-detection commands (`paket diff`, `manifest check`)
118
+ return `7` when drift is found — CI gates can react to that.
119
+
120
+ ## 7. Consumer Plugin API
121
+
122
+ The platform CLI loads consumer plugins via the `extensions:` field of the
123
+ ManifestContribution. Each plugin registers its own commands
124
+ under its own namespace (`extras:`, `billing:`, or similar).
125
+
126
+ Plugin commands automatically inherit:
127
+
128
+ - identity resolution (§1)
129
+ - MFA enforcement (§2 — the plugin author marks a command as `requireMfa: true`)
130
+ - production confirm (§3 — automatic when `mutates: true`)
131
+ - output format flags (§4)
132
+ - audit logging (§5 — the plugin supplies `action` + `entity` stub, the platform writes the rest)
133
+ - exit codes (§6 — the plugin throws `CliError` subclasses)
134
+
135
+ ## 8. Example Workflow
136
+
137
+ ```bash
138
+ # Read operation, no identity required
139
+ $ myapp mandant list --output=json | jq '.[] | select(.status=="ACTIVE")'
140
+
141
+ # Writing with identity, dry-run as default
142
+ $ myapp paket apply config/plans.yaml
143
+ ℹ Diff: 2 Pläne aktualisiert, 1 Bundle neu.
144
+ ℹ Dry-run — nutze --apply zum Schreiben.
145
+
146
+ # Writing with mandatory MFA
147
+ $ myapp paket apply config/plans.yaml --apply
148
+ ℹ Erfordert MFA-Bestätigung.
149
+ ? TOTP-Code: 482 159
150
+ ✓ PlanCatalog aktualisiert. AuditLog: PLAN_CATALOG_UPDATE.
151
+
152
+ # Production confirm
153
+ $ NODE_ENV=production myapp pilot grant pilot-schmidt --as=admin@example.com
154
+ ? Tippe production zur Bestätigung: production
155
+ ℹ Erfordert MFA-Bestätigung.
156
+ ? TOTP-Code: 217 998
157
+ ✓ Pilot-Grant gespeichert.
158
+ ```
package/index.cjs ADDED
@@ -0,0 +1,18 @@
1
+ // @saasicat/spec — CommonJS-Entrypoint
2
+ const adminManifestSchema = require('./schemas/admin-manifest.schema.json');
3
+ const planCatalogSchema = require('./schemas/plan-catalog.schema.json');
4
+ const promoCodeSchema = require('./schemas/promo-code.schema.json');
5
+ const auditEventSchema = require('./schemas/audit-event.schema.json');
6
+
7
+ module.exports = {
8
+ adminManifestSchema,
9
+ planCatalogSchema,
10
+ promoCodeSchema,
11
+ auditEventSchema,
12
+ SCHEMAS: {
13
+ adminManifest: adminManifestSchema,
14
+ planCatalog: planCatalogSchema,
15
+ promoCode: promoCodeSchema,
16
+ auditEvent: auditEventSchema,
17
+ },
18
+ };
package/index.d.cts ADDED
@@ -0,0 +1,13 @@
1
+ // @saasicat/spec — TS-Definitionen für CommonJS-Konsumenten.
2
+
3
+ export declare const adminManifestSchema: Record<string, unknown>;
4
+ export declare const planCatalogSchema: Record<string, unknown>;
5
+ export declare const promoCodeSchema: Record<string, unknown>;
6
+ export declare const auditEventSchema: Record<string, unknown>;
7
+
8
+ export declare const SCHEMAS: {
9
+ readonly adminManifest: Record<string, unknown>;
10
+ readonly planCatalog: Record<string, unknown>;
11
+ readonly promoCode: Record<string, unknown>;
12
+ readonly auditEvent: Record<string, unknown>;
13
+ };
package/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ // @saasicat/spec — TS definitions for the schema export.
2
+ // Schemas are loaded at runtime as JSON; this is only the type shell.
3
+
4
+ export declare const adminManifestSchema: Record<string, unknown>;
5
+ export declare const planCatalogSchema: Record<string, unknown>;
6
+ export declare const promoCodeSchema: Record<string, unknown>;
7
+ export declare const auditEventSchema: Record<string, unknown>;
8
+
9
+ export declare const SCHEMAS: {
10
+ readonly adminManifest: Record<string, unknown>;
11
+ readonly planCatalog: Record<string, unknown>;
12
+ readonly promoCode: Record<string, unknown>;
13
+ readonly auditEvent: Record<string, unknown>;
14
+ };
package/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // @saasicat/spec — ESM entrypoint
2
+ // Exports all JSON schemas of the platform.
3
+ // Consumed by Nest and (prospectively) Django implementations
4
+ // as well as by CI tools (`<app> manifest check`) that validate against the schemas.
5
+ import adminManifestSchema from './schemas/admin-manifest.schema.json' with { type: 'json' };
6
+ import planCatalogSchema from './schemas/plan-catalog.schema.json' with { type: 'json' };
7
+ import promoCodeSchema from './schemas/promo-code.schema.json' with { type: 'json' };
8
+ import auditEventSchema from './schemas/audit-event.schema.json' with { type: 'json' };
9
+
10
+ export { adminManifestSchema, planCatalogSchema, promoCodeSchema, auditEventSchema };
11
+
12
+ export const SCHEMAS = {
13
+ adminManifest: adminManifestSchema,
14
+ planCatalog: planCatalogSchema,
15
+ promoCode: promoCodeSchema,
16
+ auditEvent: auditEventSchema,
17
+ };
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@saasicat/spec",
3
+ "version": "0.2.0",
4
+ "description": "Language-neutral spec of the SaaS platform: JSON Schemas, OpenAPI contract, Prisma fragments, acceptance scenarios.",
5
+ "type": "module",
6
+ "main": "./index.cjs",
7
+ "module": "./index.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./index.d.ts",
13
+ "default": "./index.js"
14
+ },
15
+ "require": {
16
+ "types": "./index.d.cts",
17
+ "default": "./index.cjs"
18
+ }
19
+ },
20
+ "./schemas/*.json": "./schemas/*.json",
21
+ "./prisma-fragments/*.prisma": "./prisma-fragments/*.prisma",
22
+ "./prisma-fragments/": "./prisma-fragments/"
23
+ },
24
+ "files": [
25
+ "index.js",
26
+ "index.cjs",
27
+ "index.d.ts",
28
+ "index.d.cts",
29
+ "schemas/",
30
+ "admin-api.openapi.yaml",
31
+ "cli-conventions.md",
32
+ "acceptance/",
33
+ "prisma-fragments/"
34
+ ],
35
+ "devDependencies": {
36
+ "ajv": "^8.17.1",
37
+ "ajv-formats": "^3.0.1"
38
+ },
39
+ "keywords": [
40
+ "saas",
41
+ "platform",
42
+ "json-schema",
43
+ "openapi",
44
+ "saasicat"
45
+ ],
46
+ "license": "Apache-2.0",
47
+ "author": "Taci Uelker",
48
+ "homepage": "https://github.com/uelker70/saasicat",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/uelker70/saasicat.git",
52
+ "directory": "packages/saas-platform-spec"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/uelker70/saasicat/issues"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "scripts": {
61
+ "test": "node --test tests/schemas.test.js"
62
+ }
63
+ }
@@ -0,0 +1,216 @@
1
+ // =============================================================================
2
+ // SaaS-Platform Prisma fragment: Subscription + Payment method
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — not a standalone schema. Consumers copy
6
+ // models into their `schema.prisma` and adapt the FK relations to their own
7
+ // `Tenant`/`User` models.
8
+ //
9
+ // Conventions:
10
+ // - Plan/feature keys as `String` — source of truth is the
11
+ // consumer's `config/plans.yaml`, validated via
12
+ // `@saasicat/spec/schemas/plan-catalog.schema.json`.
13
+ // Whoever prefers Postgres enums can declare an enum locally and
14
+ // cast the field via `@db.<EnumName>` — not a platform requirement.
15
+ // - FK columns to consumer models are declared as `String` with a
16
+ // commented-out example relation. The consumer adds the `@relation`
17
+ // with their own `Tenant`/`User` model.
18
+ // - `@@map` names are the canonical table names — please do not
19
+ // change them, otherwise the shared migration path breaks in P1.5+.
20
+ //
21
+ // Accompanying fragments:
22
+ // - 03-plan-versions.prisma (PlanVersion — referenced here)
23
+ // - 02-promo-code.prisma (PromoCodeRedemption has 1:1 to Subscription)
24
+
25
+ // -----------------------------------------------------------------------------
26
+ // Universal enums — project-independent, identical in every consumer.
27
+ // -----------------------------------------------------------------------------
28
+
29
+ enum BillingCycle {
30
+ MONTHLY
31
+ YEARLY
32
+ }
33
+
34
+ enum SubscriptionStatus {
35
+ TRIAL
36
+ ACTIVE
37
+ PAST_DUE
38
+ CANCELED
39
+ PENDING_SALES
40
+ }
41
+
42
+ enum SubscriptionPaymentType {
43
+ CARD
44
+ SEPA
45
+ PAYPAL
46
+ KLARNA
47
+ INVOICE
48
+ }
49
+
50
+ // -----------------------------------------------------------------------------
51
+ // Subscription — one per Tenant. Binds to PlanVersion (see 03-plan-versions).
52
+ // -----------------------------------------------------------------------------
53
+
54
+ model Subscription {
55
+ id String @id @default(uuid())
56
+ tenantId String @unique
57
+ // Plan key from plans.yaml (`plans[].id`). Whoever wants an enum: declare locally.
58
+ plan String
59
+ billingCycle BillingCycle @default(YEARLY)
60
+ status SubscriptionStatus @default(TRIAL)
61
+
62
+ trialEndsAt DateTime?
63
+ startedAt DateTime?
64
+ canceledAt DateTime?
65
+
66
+ // Renewal period (maintained by PlanVersionRenewalService).
67
+ currentPeriodStart DateTime?
68
+ currentPeriodEnd DateTime?
69
+
70
+ // Plan versioning — binds to a published PlanVersion.
71
+ // `pendingPlanVersion*` concern a version change within the
72
+ // same plan; `pendingPlan` the change to a different plan. Both
73
+ // can coexist.
74
+ //
75
+ // Since SPEC_V2 §11.1 M5, `planVersionId` is **nullable**: a
76
+ // Subscription may have either `planVersionId` or
77
+ // `businessTypeVersionId` (or both) set. The DB CHECK
78
+ // constraint "at least one of the two set" is added by the consumer in
79
+ // the SQL migration (Prisma cannot express this directly):
80
+ // ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_plan_or_bt_check
81
+ // CHECK (plan_version_id IS NOT NULL OR business_type_version_id IS NOT NULL);
82
+ planVersionId String?
83
+ pendingPlanVersionId String?
84
+ pendingPlanVersionEffectiveAt DateTime?
85
+ pendingPlanVersionAccepted Boolean @default(false)
86
+ pendingPlanVersionAcceptedAt DateTime?
87
+ pendingPlanVersionAcceptedByUserId String?
88
+ pendingPlanVersionNotifiedAt DateTime?
89
+ pendingPlanVersionReminderSentAt DateTime?
90
+
91
+ // BusinessType composition (SPEC_V2 §11.1 M5). Optional per app: apps
92
+ // with a pure plan model leave the field at null; apps with domain
93
+ // verticals (e.g. club types)
94
+ // set the Subscription to a concrete published BusinessTypeVersion.
95
+ // Aggregation in EntitlementService.computeLimits see
96
+ // GESCHAEFTSTYP_SPEC.md §6.
97
+ businessTypeVersionId String?
98
+
99
+ // Trial / scheduled plan changes
100
+ trialEntitlementPlan String? // Default entitlement during TRIAL
101
+ postTrialPlan String? // Target package after trial
102
+ pendingPlan String? // Downgrade/change at period end
103
+ pendingBillingCycle BillingCycle?
104
+ pendingEffectiveAt DateTime?
105
+
106
+ // Special contract / pilot — audit (who/when) lives in the AuditLog.
107
+ customMonthlyNet Decimal? @db.Decimal(10, 2)
108
+ customLimits Json? // { maxUsers?, maxVehicles?, maxStorageGb?, features?: string[] }
109
+ customNote String?
110
+ isPilot Boolean @default(false)
111
+ pilotEndsAt DateTime?
112
+ pilotNote String?
113
+
114
+ // Package consistency (METAMODELL §17a) — references the CheckoutOffer
115
+ // from which the Subscription originated; `packageSnapshot` is the offer
116
+ // frozen at closing time (plan + promo + bundles +
117
+ // quotas + price breakdown). Price-fixed billing
118
+ // basis; null for legacy/direct subscriptions without an onboarding offer.
119
+ checkoutOfferId String?
120
+ packageSnapshot Json?
121
+
122
+ createdAt DateTime @default(now())
123
+ updatedAt DateTime @updatedAt
124
+
125
+ // Relations — consumer must define `Tenant` and enable the relation.
126
+ // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
127
+ paymentMethod SubscriptionPaymentMethod?
128
+ promoRedemption PromoCodeRedemption? // see 02-promo-code.prisma
129
+ planVersion PlanVersion? @relation("SubscriptionPlanVersion", fields: [planVersionId], references: [id])
130
+ pendingPlanVersion PlanVersion? @relation("SubscriptionPendingPlanVersion", fields: [pendingPlanVersionId], references: [id])
131
+ businessTypeVersion BusinessTypeVersion? @relation("SubscriptionBusinessTypeVersion", fields: [businessTypeVersionId], references: [id])
132
+
133
+ @@index([tenantId])
134
+ @@index([planVersionId])
135
+ @@index([pendingPlanVersionId])
136
+ @@index([businessTypeVersionId])
137
+ @@index([currentPeriodEnd])
138
+ @@map("subscriptions")
139
+ }
140
+
141
+ // -----------------------------------------------------------------------------
142
+ // SubscriptionPaymentMethod — masked payment data per Subscription.
143
+ // -----------------------------------------------------------------------------
144
+
145
+ model SubscriptionPaymentMethod {
146
+ id String @id @default(uuid())
147
+ subscriptionId String @unique
148
+ type SubscriptionPaymentType
149
+
150
+ // Card (masked)
151
+ cardName String?
152
+ cardBrand String?
153
+ cardLast4 String?
154
+ cardExp String?
155
+
156
+ // SEPA (masked)
157
+ ibanLast4 String?
158
+ ibanName String?
159
+
160
+ // PayPal
161
+ paypalEmail String?
162
+
163
+ // Klarna
164
+ klarnaPlan String? // "invoice" | "instalments"
165
+
166
+ createdAt DateTime @default(now())
167
+ updatedAt DateTime @updatedAt
168
+
169
+ subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
170
+
171
+ @@map("subscription_payment_methods")
172
+ }
173
+
174
+ // -----------------------------------------------------------------------------
175
+ // CheckoutOffer — immutable package snapshot from the website through to the
176
+ // Subscription (METAMODELL §17a).
177
+ //
178
+ // On a package click on the pricing page, `POST /public/checkout-offer` creates
179
+ // an offer; its `id` travels as `?offer=<id>` into onboarding. There
180
+ // the tenant customizes it (bundles/quotas) — `PATCH` writes
181
+ // the delta back. On subscription creation the offer becomes `consumed` and
182
+ // is frozen as `Subscription.packageSnapshot`.
183
+ //
184
+ // Global table (no RLS) — the offer is created before the tenant is created.
185
+ // -----------------------------------------------------------------------------
186
+
187
+ model CheckoutOffer {
188
+ id String @id @default(uuid())
189
+ projectKey String
190
+
191
+ planKey String
192
+ planVersionId String?
193
+ billingCycle String // monthly | yearly
194
+
195
+ promotionId String?
196
+ promoCode String?
197
+
198
+ bundles Json @default("[]") // string[]
199
+ bundleVersionIds Json @default("[]") // concrete BundleVersion IDs (V3)
200
+
201
+ priceBreakdown Json
202
+ lineItems Json @default("[]") // ContractLineItem snapshot shape before contract conclusion
203
+ promotionSnapshots Json @default("[]")
204
+ promoCodeSnapshot Json?
205
+ locale String @default("de")
206
+ validUntil DateTime?
207
+
208
+ status String @default("open") // open | consumed | expired
209
+ consumedAt DateTime?
210
+
211
+ createdAt DateTime @default(now())
212
+ updatedAt DateTime @updatedAt
213
+
214
+ @@index([projectKey, status])
215
+ @@map("checkout_offers")
216
+ }
@@ -0,0 +1,145 @@
1
+ // =============================================================================
2
+ // SaaS-Platform Prisma fragment: promo codes
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // Accompanying fragments:
8
+ // - 01-subscription.prisma (PromoCodeRedemption is 1:1 with Subscription)
9
+
10
+ // -----------------------------------------------------------------------------
11
+ // Universal enums.
12
+ // -----------------------------------------------------------------------------
13
+
14
+ enum PromoCodeValueType {
15
+ PERCENT
16
+ ABSOLUTE
17
+ }
18
+
19
+ enum PromoCodeDurationType {
20
+ ONCE
21
+ MONTHS
22
+ BILLING_CYCLES
23
+ }
24
+
25
+ enum PromoCodeStatus {
26
+ ACTIVE
27
+ PAUSED
28
+ EXHAUSTED
29
+ EXPIRED
30
+ }
31
+
32
+ enum PromoCodeRedemptionStatus {
33
+ ACTIVE
34
+ REVERSED
35
+ EXPIRED
36
+ }
37
+
38
+ // -----------------------------------------------------------------------------
39
+ // PromoCode — marketing discount code for onboarding checkout.
40
+ // -----------------------------------------------------------------------------
41
+
42
+ model PromoCode {
43
+ id String @id @default(uuid())
44
+ code String @unique // case-insensitive, stored in UPPER
45
+ valueType PromoCodeValueType
46
+ value Decimal @db.Decimal(8, 2) // % for PERCENT, EUR for ABSOLUTE
47
+
48
+ // Duration
49
+ durationType PromoCodeDurationType @default(ONCE)
50
+ durationValue Int? // null for ONCE; 1–24 for MONTHS / BILLING_CYCLES
51
+
52
+ validFrom DateTime?
53
+ validUntil DateTime?
54
+ maxRedemptions Int? // null = unlimited
55
+
56
+ // Availability-relevant — counts only redemptions with status=ACTIVE.
57
+ // Incremented atomically in the redeem() path, decremented on trial cancellation.
58
+ redemptionsCount Int @default(0)
59
+
60
+ // Plan whitelist as String[] — values are plan IDs from plans.yaml.
61
+ // Consumers using a Postgres enum can cast the field locally.
62
+ appliesToPlans String[] // empty = all plans; otherwise whitelist
63
+ appliesToBilling BillingCycle? // null = both; otherwise only MONTHLY or YEARLY
64
+
65
+ // Anti-abuse
66
+ firstTimeCustomersOnly Boolean @default(true)
67
+ minimumPlanAmountGross Decimal? @db.Decimal(10, 2)
68
+ allowZeroInvoice Boolean @default(false)
69
+
70
+ status PromoCodeStatus @default(ACTIVE)
71
+ description String?
72
+ campaignTag String?
73
+
74
+ // Accounting — SKR account no. (e.g. "4736"); if null, the
75
+ // consumer falls back to the system default (configurable in the consumer).
76
+ revenueDeductionAccount String?
77
+
78
+ createdById String // SUPER_ADMIN user ID (consumer FK, see header)
79
+ createdAt DateTime @default(now())
80
+ updatedAt DateTime @updatedAt
81
+ deletedAt DateTime? // soft delete; never hard-delete for audit reasons
82
+
83
+ redemptions PromoCodeRedemption[]
84
+ validationLogs PromoCodeValidationLog[]
85
+
86
+ @@index([status, validFrom, validUntil])
87
+ @@index([campaignTag])
88
+ @@map("promo_codes")
89
+ }
90
+
91
+ // -----------------------------------------------------------------------------
92
+ // PromoCodeRedemption — a subscription can have only one active code.
93
+ // -----------------------------------------------------------------------------
94
+
95
+ model PromoCodeRedemption {
96
+ id String @id @default(uuid())
97
+ promoCodeId String
98
+ subscriptionId String @unique
99
+ tenantId String
100
+
101
+ // Snapshot of the code rule at redemption time.
102
+ appliedValueType PromoCodeValueType
103
+ appliedValue Decimal @db.Decimal(8, 2)
104
+ appliedDurationType PromoCodeDurationType
105
+ appliedDurationValue Int?
106
+
107
+ // Validity window — endsAt null for ONCE, otherwise computed from the snapshot.
108
+ startsAt DateTime
109
+ endsAt DateTime?
110
+
111
+ status PromoCodeRedemptionStatus @default(ACTIVE)
112
+ redeemedAt DateTime @default(now())
113
+ reversedAt DateTime?
114
+
115
+ promoCode PromoCode @relation(fields: [promoCodeId], references: [id])
116
+ subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
117
+ // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
118
+
119
+ @@index([tenantId])
120
+ @@index([promoCodeId, status])
121
+ @@index([startsAt, endsAt])
122
+ @@map("promo_code_redemptions")
123
+ }
124
+
125
+ // -----------------------------------------------------------------------------
126
+ // PromoCodeValidationLog — audit trail of all validation attempts
127
+ // (including failed NOT_FOUND/EXPIRED), for anti-abuse analysis.
128
+ // -----------------------------------------------------------------------------
129
+
130
+ model PromoCodeValidationLog {
131
+ id String @id @default(uuid())
132
+ promoCodeId String? // null for NOT_FOUND attempts
133
+ codeAttempt String // input in UPPER (retained even for NOT_FOUND)
134
+ ipHash String? // hash of the IP, no plaintext (data protection)
135
+ sessionId String? // onboarding session / CSRF token
136
+ result String // "VALID" | "EXPIRED" | "EXHAUSTED" | "NOT_FOUND" | "PLAN_MISMATCH" | …
137
+ createdAt DateTime @default(now())
138
+
139
+ promoCode PromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
140
+
141
+ @@index([codeAttempt, createdAt])
142
+ @@index([ipHash, createdAt])
143
+ @@index([sessionId, createdAt])
144
+ @@map("promo_code_validation_logs")
145
+ }