@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,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
|
+
}
|