@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,1724 @@
1
+ openapi: 3.1.0
2
+
3
+ # SaaS Platform · SuperAdmin API contract
4
+ # Package: @saasicat/spec
5
+ #
6
+ # NORMATIVE contract for the SuperAdmin backend. Every implementation
7
+ # (reference: @saasicat/nest) MUST fulfill this contract. Acceptance tests
8
+ # in saas-platform-spec/acceptance/ validate implementations against exactly
9
+ # these endpoints.
10
+ #
11
+ # Path convention (see SPEC §8.1): All admin endpoints live under
12
+ # `/api/v1/admin/...`. The `v1` prefix matches the globalPrefix of the
13
+ # consumer backends. The frontend slug `/admin/...`
14
+ # is configurable independently.
15
+ #
16
+ # Deliberately not included:
17
+ # - Tenant end-customer API (self-service plan change) — separate file
18
+ # - Onboarding API — separate file (uses PromoCode preview)
19
+ # - X-Rechnung — its own package, its own OpenAPI file
20
+
21
+ info:
22
+ title: SaaS Platform SuperAdmin API
23
+ version: 0.1.0-draft
24
+ description: |
25
+ Read and write operations for platform administration:
26
+ tenants, users, subscriptions, promo codes, audit log,
27
+ manifest discovery, plus plan catalog lookup.
28
+ Authentication via JWT with role SUPER_ADMIN; all write
29
+ operations additionally require a TOTP code in the header
30
+ X-Mfa-Code, provided the admin has enabled MFA (otherwise 401
31
+ MFA_REQUIRED). The manifest is never a security source (see SPEC §10.4).
32
+
33
+ servers:
34
+ - url: https://{host}/api/v1/admin
35
+ variables:
36
+ host:
37
+ default: localhost:3000
38
+ description: Consumer-specific host (domain of the respective app)
39
+
40
+ tags:
41
+ - name: tenants
42
+ - name: users
43
+ - name: plans
44
+ - name: subscriptions
45
+ - name: promo-codes
46
+ - name: audit
47
+ - name: dashboard
48
+ - name: mfa
49
+ - name: manifest
50
+ - name: catalog
51
+
52
+ security:
53
+ - bearerAuth: []
54
+
55
+ paths:
56
+ # ────────────────────────────────────────────────────────
57
+ # Manifest (UI discovery, SPEC §4 + §8.2-8.3)
58
+ # ────────────────────────────────────────────────────────
59
+
60
+ /boot:
61
+ get:
62
+ tags: [manifest]
63
+ security: [] # PUBLIC — no auth
64
+ summary: Minimal branding configuration for the login page
65
+ description: |
66
+ Pre-login boot. Contains branding only (project.key,
67
+ displayName, logoUrl, environment) — NO capabilities, pages,
68
+ or endpoints. The schema forbids further fields. See SPEC §10.4.
69
+ responses:
70
+ '200':
71
+ description: Public boot
72
+ content:
73
+ application/json:
74
+ schema:
75
+ type: object
76
+ required: [project]
77
+ additionalProperties: false
78
+ properties:
79
+ project:
80
+ type: object
81
+ required: [key, displayName]
82
+ additionalProperties: false
83
+ properties:
84
+ key: { type: string }
85
+ displayName: { type: string }
86
+ logoUrl: { type: string }
87
+ environment:
88
+ {
89
+ type: string,
90
+ enum: [production, staging, development],
91
+ }
92
+
93
+ /manifest:
94
+ get:
95
+ tags: [manifest]
96
+ summary: Complete UI discovery manifest (auth required)
97
+ description: |
98
+ Auth required: SUPER_ADMIN. Rejects non-SUPER_ADMIN requests with 403.
99
+ Cache: ETag = manifestHash + Cache-Control private, max-age=60,
100
+ must-revalidate. Schema in admin-manifest.schema.json.
101
+ parameters:
102
+ - name: If-None-Match
103
+ in: header
104
+ schema: { type: string }
105
+ description: ETag = manifestHash. On match → 304.
106
+ responses:
107
+ '200':
108
+ description: Manifest
109
+ headers:
110
+ ETag:
111
+ schema: { type: string }
112
+ Cache-Control:
113
+ schema: { type: string }
114
+ example: 'private, max-age=60, must-revalidate'
115
+ content:
116
+ application/json:
117
+ schema:
118
+ $ref: 'admin-manifest.schema.json'
119
+ '304':
120
+ description: Unchanged since ETag
121
+ '403':
122
+ description: Not SUPER_ADMIN
123
+
124
+ /manifest/reload:
125
+ post:
126
+ tags: [manifest]
127
+ summary: Rebuild the manifest (after plans.yaml change)
128
+ x-mfa-required: true
129
+ responses:
130
+ '200':
131
+ description: Loaded
132
+ content:
133
+ application/json:
134
+ schema:
135
+ type: object
136
+ required: [manifestHash, reloadedAt]
137
+ properties:
138
+ manifestHash: { type: string }
139
+ reloadedAt: { type: string, format: date-time }
140
+
141
+ # ────────────────────────────────────────────────────────
142
+ # Dashboard
143
+ # ────────────────────────────────────────────────────────
144
+ /dashboard/stats:
145
+ get:
146
+ tags: [dashboard]
147
+ summary: Global platform metrics
148
+ responses:
149
+ '200':
150
+ description: KPIs
151
+ content:
152
+ application/json:
153
+ schema:
154
+ $ref: '#/components/schemas/DashboardStats'
155
+
156
+ # ────────────────────────────────────────────────────────
157
+ # Plan catalog (read-only — maintained via file + reload endpoint)
158
+ # ────────────────────────────────────────────────────────
159
+ /plans:
160
+ get:
161
+ tags: [plans]
162
+ summary: Currently loaded plan catalog
163
+ responses:
164
+ '200':
165
+ description: Catalog
166
+ content:
167
+ application/json:
168
+ schema:
169
+ $ref: '#/components/schemas/PlanCatalog'
170
+
171
+ /plans/reload:
172
+ post:
173
+ tags: [plans]
174
+ summary: Reload the plan catalog from file (for admin CLI / hot-reload dev)
175
+ x-mfa-required: true
176
+ responses:
177
+ '200':
178
+ description: Loaded
179
+ content:
180
+ application/json:
181
+ schema:
182
+ type: object
183
+ properties:
184
+ loadedAt: { type: string, format: date-time }
185
+ planCount: { type: integer }
186
+ source: { type: string, description: Path or URL }
187
+
188
+ /plans/last-update:
189
+ get:
190
+ tags: [plans]
191
+ summary: When the catalog was last loaded + hash
192
+ responses:
193
+ '200':
194
+ description: Meta
195
+ content:
196
+ application/json:
197
+ schema:
198
+ type: object
199
+ properties:
200
+ loadedAt: { type: string, format: date-time }
201
+ hash: { type: string, description: SHA-256 of the YAML content }
202
+
203
+ # ────────────────────────────────────────────────────────
204
+ # Tenants
205
+ # ────────────────────────────────────────────────────────
206
+ /tenants:
207
+ get:
208
+ tags: [tenants]
209
+ summary: List of all tenants
210
+ parameters:
211
+ - $ref: '#/components/parameters/Page'
212
+ - $ref: '#/components/parameters/PageSize'
213
+ - name: status
214
+ in: query
215
+ schema: { type: string, enum: [active, suspended, deleted] }
216
+ - name: plan
217
+ in: query
218
+ schema: { type: string }
219
+ - name: search
220
+ in: query
221
+ schema: { type: string, description: 'Full-text over slug/name/email' }
222
+ responses:
223
+ '200':
224
+ description: Paginated list
225
+ content:
226
+ application/json:
227
+ schema:
228
+ allOf:
229
+ - $ref: '#/components/schemas/PaginatedEnvelope'
230
+ - properties:
231
+ items:
232
+ type: array
233
+ items: { $ref: '#/components/schemas/AdminTenantRow' }
234
+
235
+ post:
236
+ tags: [tenants]
237
+ summary: Create a tenant manually (bypasses the onboarding flow)
238
+ x-mfa-required: true
239
+ requestBody:
240
+ required: true
241
+ content:
242
+ application/json:
243
+ schema: { $ref: '#/components/schemas/CreateTenantRequest' }
244
+ responses:
245
+ '201':
246
+ description: Created
247
+ content:
248
+ application/json:
249
+ schema: { $ref: '#/components/schemas/AdminTenantDetail' }
250
+
251
+ /tenants/{slug}:
252
+ parameters:
253
+ - $ref: '#/components/parameters/TenantSlug'
254
+ get:
255
+ tags: [tenants]
256
+ summary: Tenant detail incl. subscription, add-ons, quota usage
257
+ responses:
258
+ '200':
259
+ description: Detail
260
+ content:
261
+ application/json:
262
+ schema: { $ref: '#/components/schemas/AdminTenantDetail' }
263
+ delete:
264
+ tags: [tenants]
265
+ summary: Soft-delete a tenant (set deletedAt)
266
+ x-mfa-required: true
267
+ requestBody:
268
+ required: true
269
+ content:
270
+ application/json:
271
+ schema:
272
+ type: object
273
+ required: [reason, confirmSlug]
274
+ properties:
275
+ reason: { type: string, minLength: 10 }
276
+ confirmSlug:
277
+ {
278
+ type: string,
279
+ description: 'Must exactly match the tenant slug',
280
+ }
281
+ responses:
282
+ '200': { description: Deleted }
283
+
284
+ /tenants/{slug}/suspend:
285
+ parameters:
286
+ - $ref: '#/components/parameters/TenantSlug'
287
+ post:
288
+ tags: [tenants]
289
+ summary: Deactivate a tenant (login is blocked, data remains)
290
+ x-mfa-required: true
291
+ requestBody:
292
+ required: true
293
+ content:
294
+ application/json:
295
+ schema:
296
+ type: object
297
+ required: [reason]
298
+ properties:
299
+ reason: { type: string, minLength: 5 }
300
+ responses:
301
+ '200': { description: Suspended }
302
+
303
+ /tenants/{slug}/reactivate:
304
+ parameters:
305
+ - $ref: '#/components/parameters/TenantSlug'
306
+ post:
307
+ tags: [tenants]
308
+ summary: Reactivate a suspended tenant
309
+ x-mfa-required: true
310
+ responses:
311
+ '200': { description: Reactivated }
312
+
313
+ /tenants/{slug}/impersonate:
314
+ parameters:
315
+ - $ref: '#/components/parameters/TenantSlug'
316
+ post:
317
+ tags: [tenants]
318
+ summary: Short-lived JWT as TENANT_ADMIN for support insight
319
+ x-mfa-required: true
320
+ requestBody:
321
+ required: true
322
+ content:
323
+ application/json:
324
+ schema:
325
+ type: object
326
+ required: [reason, ttlMinutes]
327
+ properties:
328
+ reason: { type: string, minLength: 5 }
329
+ ttlMinutes: { type: integer, minimum: 5, maximum: 60 }
330
+ responses:
331
+ '200':
332
+ description: Impersonation token
333
+ content:
334
+ application/json:
335
+ schema:
336
+ type: object
337
+ properties:
338
+ token: { type: string }
339
+ expiresAt: { type: string, format: date-time }
340
+ auditId: { type: string }
341
+
342
+ /tenants/{slug}/export:
343
+ parameters:
344
+ - $ref: '#/components/parameters/TenantSlug'
345
+ get:
346
+ tags: [tenants]
347
+ summary: GDPR / audit export of all tenant data as a ZIP
348
+ x-mfa-required: true
349
+ responses:
350
+ '200':
351
+ description: ZIP stream
352
+ content:
353
+ application/zip:
354
+ schema: { type: string, format: binary }
355
+
356
+ # ────────────────────────────────────────────────────────
357
+ # Subscription per tenant
358
+ # ────────────────────────────────────────────────────────
359
+ /tenants/{slug}/subscription:
360
+ parameters:
361
+ - $ref: '#/components/parameters/TenantSlug'
362
+ get:
363
+ tags: [subscriptions]
364
+ summary: Current subscription + add-ons + effective limits
365
+ responses:
366
+ '200':
367
+ description: Subscription
368
+ content:
369
+ application/json:
370
+ schema: { $ref: '#/components/schemas/SubscriptionDetail' }
371
+
372
+ patch:
373
+ tags: [subscriptions]
374
+ summary: Change plan, custom limits, or pilot flag
375
+ x-mfa-required: true
376
+ requestBody:
377
+ required: true
378
+ content:
379
+ application/json:
380
+ schema:
381
+ type: object
382
+ properties:
383
+ planId: { type: string }
384
+ billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
385
+ customLimits:
386
+ type: object
387
+ additionalProperties:
388
+ type: integer
389
+ description: 'quotaKey → maxValue (-1 = unlimited)'
390
+ customMonthlyNet: { type: number, format: decimal }
391
+ isPilot: { type: boolean }
392
+ pilotEndsAt: { type: string, format: date-time }
393
+ pilotNote: { type: string }
394
+ customNote: { type: string }
395
+ responses:
396
+ '200':
397
+ description: Changed
398
+ content:
399
+ application/json:
400
+ schema: { $ref: '#/components/schemas/SubscriptionDetail' }
401
+
402
+ # ────────────────────────────────────────────────────────
403
+ # Users
404
+ # ────────────────────────────────────────────────────────
405
+ /users:
406
+ get:
407
+ tags: [users]
408
+ summary: Cross-tenant user search
409
+ parameters:
410
+ - name: email
411
+ in: query
412
+ schema: { type: string }
413
+ - name: tenantSlug
414
+ in: query
415
+ schema: { type: string }
416
+ - $ref: '#/components/parameters/Page'
417
+ responses:
418
+ '200':
419
+ description: Paginated list
420
+ content:
421
+ application/json:
422
+ schema:
423
+ allOf:
424
+ - $ref: '#/components/schemas/PaginatedEnvelope'
425
+ - properties:
426
+ items:
427
+ type: array
428
+ items: { $ref: '#/components/schemas/AdminUserRow' }
429
+
430
+ /users/{id}/reset-password:
431
+ parameters:
432
+ - name: id
433
+ in: path
434
+ required: true
435
+ schema: { type: string }
436
+ post:
437
+ tags: [users]
438
+ summary: Trigger a password-reset email (sends no plaintext passwords)
439
+ x-mfa-required: true
440
+ requestBody:
441
+ required: true
442
+ content:
443
+ application/json:
444
+ schema:
445
+ type: object
446
+ required: [reason]
447
+ properties:
448
+ reason: { type: string, minLength: 5 }
449
+ responses:
450
+ '200': { description: Email sent }
451
+
452
+ /users/{id}/deactivate:
453
+ parameters:
454
+ - name: id
455
+ in: path
456
+ required: true
457
+ schema: { type: string }
458
+ post:
459
+ tags: [users]
460
+ summary: Deactivate a user (block login)
461
+ x-mfa-required: true
462
+ requestBody:
463
+ required: true
464
+ content:
465
+ application/json:
466
+ schema:
467
+ type: object
468
+ required: [reason]
469
+ properties:
470
+ reason: { type: string, minLength: 5 }
471
+ responses:
472
+ '200': { description: Deactivated }
473
+
474
+ /tenants/{slug}/users/reassign-admin:
475
+ parameters:
476
+ - $ref: '#/components/parameters/TenantSlug'
477
+ post:
478
+ tags: [users]
479
+ summary: Transfer the TENANT_ADMIN role to another user
480
+ x-mfa-required: true
481
+ requestBody:
482
+ required: true
483
+ content:
484
+ application/json:
485
+ schema:
486
+ type: object
487
+ required: [newAdminUserId]
488
+ properties:
489
+ newAdminUserId: { type: string }
490
+ demoteOldAdminTo:
491
+ type: string
492
+ description: 'Platform-neutral role of the previous admin. Default: TENANT_MEMBER.'
493
+ default: TENANT_MEMBER
494
+ responses:
495
+ '200': { description: Assigned }
496
+
497
+ # ────────────────────────────────────────────────────────
498
+ # Promo-Codes
499
+ # ────────────────────────────────────────────────────────
500
+ /promo-codes:
501
+ get:
502
+ tags: [promo-codes]
503
+ summary: List of all promo codes
504
+ parameters:
505
+ - name: status
506
+ in: query
507
+ schema: { type: string, enum: [ACTIVE, PAUSED, EXHAUSTED, EXPIRED] }
508
+ - name: campaignTag
509
+ in: query
510
+ schema: { type: string }
511
+ - $ref: '#/components/parameters/Page'
512
+ responses:
513
+ '200':
514
+ description: List
515
+ content:
516
+ application/json:
517
+ schema:
518
+ allOf:
519
+ - $ref: '#/components/schemas/PaginatedEnvelope'
520
+ - properties:
521
+ items:
522
+ type: array
523
+ items: { $ref: '#/components/schemas/PromoCodeDto' }
524
+
525
+ post:
526
+ tags: [promo-codes]
527
+ summary: Create a new promo code
528
+ x-mfa-required: true
529
+ requestBody:
530
+ required: true
531
+ content:
532
+ application/json:
533
+ schema: { $ref: '#/components/schemas/CreatePromoCodeRequest' }
534
+ responses:
535
+ '201':
536
+ description: Created
537
+ content:
538
+ application/json:
539
+ schema: { $ref: '#/components/schemas/PromoCodeDto' }
540
+
541
+ /promo-codes/{id}:
542
+ parameters:
543
+ - name: id
544
+ in: path
545
+ required: true
546
+ schema: { type: string, format: uuid }
547
+ get:
548
+ tags: [promo-codes]
549
+ responses:
550
+ '200':
551
+ description: Detail
552
+ content:
553
+ application/json:
554
+ schema: { $ref: '#/components/schemas/PromoCodeDetail' }
555
+
556
+ patch:
557
+ tags: [promo-codes]
558
+ x-mfa-required: true
559
+ requestBody:
560
+ required: true
561
+ content:
562
+ application/json:
563
+ schema: { $ref: '#/components/schemas/UpdatePromoCodeRequest' }
564
+ responses:
565
+ '200':
566
+ description: Updated
567
+ content:
568
+ application/json:
569
+ schema: { $ref: '#/components/schemas/PromoCodeDto' }
570
+
571
+ delete:
572
+ tags: [promo-codes]
573
+ summary: Soft-delete (audit is retained)
574
+ x-mfa-required: true
575
+ responses:
576
+ '200': { description: Deleted }
577
+
578
+ # ────────────────────────────────────────────────────────
579
+ # Audit log
580
+ # ────────────────────────────────────────────────────────
581
+ /audit:
582
+ get:
583
+ tags: [audit]
584
+ summary: Audit log with filters
585
+ parameters:
586
+ - name: tenantId
587
+ in: query
588
+ schema: { type: string }
589
+ - name: userId
590
+ in: query
591
+ schema: { type: string }
592
+ - name: entity
593
+ in: query
594
+ schema: { type: string }
595
+ - name: entityId
596
+ in: query
597
+ schema: { type: string }
598
+ - name: action
599
+ in: query
600
+ schema: { type: string }
601
+ - name: actorTag
602
+ in: query
603
+ schema: { type: string, description: 'e.g. cli:* for all CLI actions' }
604
+ - name: from
605
+ in: query
606
+ schema: { type: string, format: date-time }
607
+ - name: to
608
+ in: query
609
+ schema: { type: string, format: date-time }
610
+ - $ref: '#/components/parameters/Page'
611
+ responses:
612
+ '200':
613
+ description: List
614
+ content:
615
+ application/json:
616
+ schema:
617
+ allOf:
618
+ - $ref: '#/components/schemas/PaginatedEnvelope'
619
+ - properties:
620
+ items:
621
+ type: array
622
+ items: { $ref: '#/components/schemas/AuditEntryDto' }
623
+
624
+ # ────────────────────────────────────────────────────────
625
+ # MFA
626
+ # ────────────────────────────────────────────────────────
627
+ /mfa/setup:
628
+ post:
629
+ tags: [mfa]
630
+ summary: Start TOTP setup (returns QR-code URI + secret)
631
+ responses:
632
+ '200':
633
+ description: Setup data
634
+ content:
635
+ application/json:
636
+ schema:
637
+ type: object
638
+ properties:
639
+ otpauthUri: { type: string }
640
+ secret: { type: string }
641
+ recoveryCodes:
642
+ type: array
643
+ items: { type: string }
644
+
645
+ /mfa/confirm:
646
+ post:
647
+ tags: [mfa]
648
+ summary: Confirm setup with the first code
649
+ requestBody:
650
+ required: true
651
+ content:
652
+ application/json:
653
+ schema:
654
+ type: object
655
+ required: [code]
656
+ properties:
657
+ code: { type: string, pattern: '^[0-9]{6}$' }
658
+ responses:
659
+ '200': { description: Activated }
660
+
661
+ # ────────────────────────────────────────────────────────
662
+ # Catalog V2 — Plan/Bundle/BusinessType/Marketing/Promotion + Discovery
663
+ # (CatalogModule, saas-platform-nest/src/catalog/*; DTO = SSOT for schemas)
664
+ # ────────────────────────────────────────────────────────
665
+
666
+ # ── Plans ──
667
+ /catalog/plans:
668
+ get:
669
+ tags: [catalog]
670
+ summary: List plan roots
671
+ responses: { '200': { description: List } }
672
+ post:
673
+ tags: [catalog]
674
+ summary: Create a plan root
675
+ x-mfa-required: true
676
+ requestBody:
677
+ required: true
678
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogPlanCreate' } } }
679
+ responses:
680
+ '201': { description: Created }
681
+ '422': { description: Validation, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
682
+
683
+ /catalog/plans/tenant-counts:
684
+ get:
685
+ tags: [catalog]
686
+ summary: Active subscriptions per planKey (across versions)
687
+ description: >-
688
+ Platform-wide count of active subscriptions grouped by planKey
689
+ (across all plan versions). Feeds the default of
690
+ tenantCountsByPlanKey in the SuperAdmin catalog UI.
691
+ responses:
692
+ '200':
693
+ description: Map planKey → number of active subscriptions
694
+ content:
695
+ application/json:
696
+ schema:
697
+ type: object
698
+ additionalProperties: { type: integer, minimum: 0 }
699
+ example: { STARTER: 12, STANDARD: 4, ENTERPRISE: 1 }
700
+
701
+ /catalog/plans/{id}:
702
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
703
+ get:
704
+ tags: [catalog]
705
+ summary: Plan root detail (incl. versions)
706
+ responses: { '200': { description: Detail } }
707
+ patch:
708
+ tags: [catalog]
709
+ summary: Update a plan root (label/description/icon/sortOrder)
710
+ x-mfa-required: true
711
+ requestBody:
712
+ required: true
713
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogStampUpdate' } } }
714
+ responses: { '200': { description: Updated } }
715
+ delete:
716
+ tags: [catalog]
717
+ summary: Soft-delete a plan root
718
+ x-mfa-required: true
719
+ responses: { '200': { description: Deleted } }
720
+
721
+ /catalog/plans/{id}/purge:
722
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
723
+ delete:
724
+ tags: [catalog]
725
+ summary: Hard-delete a plan root (only without active references)
726
+ x-mfa-required: true
727
+ responses:
728
+ '200': { description: Purged }
729
+ '422': { description: Still referenced, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
730
+
731
+ /catalog/plans/{id}/versions:
732
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
733
+ get:
734
+ tags: [catalog]
735
+ summary: List the plan versions of a plan
736
+ responses: { '200': { description: List } }
737
+ post:
738
+ tags: [catalog]
739
+ summary: Create a plan version draft (unpublished, publishedAt=null)
740
+ x-mfa-required: true
741
+ requestBody:
742
+ required: true
743
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogPlanVersionDraft' } } }
744
+ responses: { '201': { description: Draft created } }
745
+
746
+ /catalog/plan-versions/{id}:
747
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
748
+ get:
749
+ tags: [catalog]
750
+ summary: Plan version detail
751
+ responses: { '200': { description: Detail } }
752
+ patch:
753
+ tags: [catalog]
754
+ summary: Update a plan version draft (while unpublished)
755
+ x-mfa-required: true
756
+ requestBody:
757
+ required: true
758
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogPlanVersionDraft' } } }
759
+ responses: { '200': { description: Updated } }
760
+ delete:
761
+ tags: [catalog]
762
+ summary: Discard a plan version draft (only unpublished)
763
+ x-mfa-required: true
764
+ responses: { '200': { description: Discarded } }
765
+
766
+ /catalog/plan-versions/{id}/publish:
767
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
768
+ post:
769
+ tags: [catalog]
770
+ summary: Publish a plan version (supersedes the predecessor)
771
+ description: |
772
+ validFrom is required if the draft does not carry one, and must
773
+ lie strictly after the predecessor version. Regressive diffs
774
+ require forceRegressive; a price of 0.00 requires allowZeroPrice.
775
+ x-mfa-required: true
776
+ requestBody:
777
+ required: false
778
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogVersionPublish' } } }
779
+ responses:
780
+ '201': { description: Published }
781
+ '422': { description: 'Regression / validFrom / price gate', content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
782
+
783
+ /catalog/plan-versions/{id}/terminate:
784
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
785
+ post:
786
+ tags: [catalog]
787
+ summary: Retire a plan version (endsAt; without a successor version)
788
+ x-mfa-required: true
789
+ requestBody:
790
+ required: true
791
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogPlanVersionTerminate' } } }
792
+ responses: { '200': { description: Terminated } }
793
+
794
+ # ── Bundles ──
795
+ /catalog/bundles:
796
+ get:
797
+ tags: [catalog]
798
+ summary: List bundle roots
799
+ responses: { '200': { description: List } }
800
+ post:
801
+ tags: [catalog]
802
+ summary: Create a bundle root
803
+ x-mfa-required: true
804
+ requestBody:
805
+ required: true
806
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBundleCreate' } } }
807
+ responses: { '201': { description: Created } }
808
+
809
+ /catalog/bundles/{id}:
810
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
811
+ get:
812
+ tags: [catalog]
813
+ summary: Bundle root detail
814
+ responses: { '200': { description: Detail } }
815
+ patch:
816
+ tags: [catalog]
817
+ summary: Update a bundle root (label/description/icon/sortOrder/i18n)
818
+ x-mfa-required: true
819
+ requestBody:
820
+ required: true
821
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBundleUpdate' } } }
822
+ responses: { '200': { description: Updated } }
823
+ delete:
824
+ tags: [catalog]
825
+ summary: Soft-delete a bundle root
826
+ x-mfa-required: true
827
+ responses: { '200': { description: Deleted } }
828
+
829
+ /catalog/bundles/{id}/versions:
830
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
831
+ get:
832
+ tags: [catalog]
833
+ summary: List bundle versions
834
+ responses: { '200': { description: List } }
835
+ post:
836
+ tags: [catalog]
837
+ summary: Create a bundle version draft (unpublished)
838
+ x-mfa-required: true
839
+ requestBody:
840
+ required: true
841
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBundleVersionDraft' } } }
842
+ responses: { '201': { description: Draft created } }
843
+
844
+ /catalog/bundle-versions/{id}:
845
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
846
+ get:
847
+ tags: [catalog]
848
+ summary: Bundle version detail
849
+ responses: { '200': { description: Detail } }
850
+ patch:
851
+ tags: [catalog]
852
+ summary: Update a bundle version draft (while unpublished)
853
+ x-mfa-required: true
854
+ requestBody:
855
+ required: true
856
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBundleVersionDraft' } } }
857
+ responses: { '200': { description: Updated } }
858
+ delete:
859
+ tags: [catalog]
860
+ summary: Discard a bundle version draft (only unpublished)
861
+ x-mfa-required: true
862
+ responses: { '200': { description: Discarded } }
863
+
864
+ /catalog/bundle-versions/{id}/publish:
865
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
866
+ post:
867
+ tags: [catalog]
868
+ summary: Publish a bundle version (supersedes the predecessor; validFrom required)
869
+ x-mfa-required: true
870
+ requestBody:
871
+ required: false
872
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogVersionPublish' } } }
873
+ responses:
874
+ '201': { description: Published }
875
+ '422': { description: 'Regression / validFrom / price gate', content: { application/json: { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }
876
+
877
+ # ── Business types ──
878
+ /catalog/business-types:
879
+ get:
880
+ tags: [catalog]
881
+ summary: List business-type roots
882
+ responses: { '200': { description: List } }
883
+ post:
884
+ tags: [catalog]
885
+ summary: Create a business-type root
886
+ x-mfa-required: true
887
+ requestBody:
888
+ required: true
889
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBusinessTypeCreate' } } }
890
+ responses: { '201': { description: Created } }
891
+
892
+ /catalog/business-types/{id}:
893
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
894
+ get:
895
+ tags: [catalog]
896
+ summary: Business-type root detail
897
+ responses: { '200': { description: Detail } }
898
+ patch:
899
+ tags: [catalog]
900
+ summary: Update a business-type root
901
+ x-mfa-required: true
902
+ requestBody:
903
+ required: true
904
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogStampUpdate' } } }
905
+ responses: { '200': { description: Updated } }
906
+ delete:
907
+ tags: [catalog]
908
+ summary: Soft-delete a business-type root
909
+ x-mfa-required: true
910
+ responses: { '200': { description: Deleted } }
911
+
912
+ /catalog/business-types/{id}/versions:
913
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
914
+ get:
915
+ tags: [catalog]
916
+ summary: List business-type versions
917
+ responses: { '200': { description: List } }
918
+ post:
919
+ tags: [catalog]
920
+ summary: Create a business-type version draft (bundle composition)
921
+ x-mfa-required: true
922
+ requestBody:
923
+ required: true
924
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBusinessTypeVersionDraft' } } }
925
+ responses: { '201': { description: Draft created } }
926
+
927
+ /catalog/business-type-versions/{id}:
928
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
929
+ get:
930
+ tags: [catalog]
931
+ summary: Business-type version detail
932
+ responses: { '200': { description: Detail } }
933
+ patch:
934
+ tags: [catalog]
935
+ summary: Update a business-type version draft
936
+ x-mfa-required: true
937
+ requestBody:
938
+ required: true
939
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBusinessTypeVersionDraft' } } }
940
+ responses: { '200': { description: Updated } }
941
+
942
+ /catalog/business-type-versions/{id}/publish:
943
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
944
+ post:
945
+ tags: [catalog]
946
+ summary: Publish a business-type version
947
+ x-mfa-required: true
948
+ requestBody:
949
+ required: false
950
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogBusinessTypeVersionPublish' } } }
951
+ responses: { '201': { description: Published } }
952
+
953
+ # ── Marketing projections + settings ──
954
+ /catalog/marketing-projections:
955
+ get:
956
+ tags: [catalog]
957
+ summary: List marketing projections
958
+ responses: { '200': { description: List } }
959
+ post:
960
+ tags: [catalog]
961
+ summary: Create a marketing projection (plan/bundle/business type, locale-specific)
962
+ x-mfa-required: true
963
+ requestBody:
964
+ required: true
965
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogMarketingProjectionCreate' } } }
966
+ responses: { '201': { description: Created } }
967
+
968
+ /catalog/marketing-projections/{id}:
969
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
970
+ get:
971
+ tags: [catalog]
972
+ summary: Marketing projection detail
973
+ responses: { '200': { description: Detail } }
974
+ patch:
975
+ tags: [catalog]
976
+ summary: Update a marketing projection
977
+ x-mfa-required: true
978
+ requestBody:
979
+ required: true
980
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogMarketingProjectionUpdate' } } }
981
+ responses: { '200': { description: Updated } }
982
+ delete:
983
+ tags: [catalog]
984
+ summary: Delete a marketing projection
985
+ x-mfa-required: true
986
+ responses: { '200': { description: Deleted } }
987
+
988
+ /catalog/marketing-settings:
989
+ get:
990
+ tags: [catalog]
991
+ summary: Read marketing settings (active locales)
992
+ responses: { '200': { description: Settings } }
993
+ put:
994
+ tags: [catalog]
995
+ summary: Set marketing settings (active locales per project)
996
+ x-mfa-required: true
997
+ requestBody:
998
+ required: true
999
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogMarketingSettingsUpdate' } } }
1000
+ responses: { '200': { description: Set } }
1001
+
1002
+ # ── Promotions ──
1003
+ /catalog/promotions:
1004
+ get:
1005
+ tags: [catalog]
1006
+ summary: List promotions
1007
+ responses: { '200': { description: List } }
1008
+ post:
1009
+ tags: [catalog]
1010
+ summary: Create a promotion (discount/intro/free months)
1011
+ x-mfa-required: true
1012
+ requestBody:
1013
+ required: true
1014
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogPromotionCreate' } } }
1015
+ responses: { '201': { description: Created } }
1016
+
1017
+ /catalog/promotions/{id}:
1018
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1019
+ get:
1020
+ tags: [catalog]
1021
+ summary: Promotion detail
1022
+ responses: { '200': { description: Detail } }
1023
+ patch:
1024
+ tags: [catalog]
1025
+ summary: Update a promotion
1026
+ x-mfa-required: true
1027
+ requestBody:
1028
+ required: true
1029
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogPromotionUpdate' } } }
1030
+ responses: { '200': { description: Updated } }
1031
+ delete:
1032
+ tags: [catalog]
1033
+ summary: Soft-delete a promotion
1034
+ x-mfa-required: true
1035
+ responses: { '200': { description: Deleted } }
1036
+
1037
+ # ── Feature / quota / capability catalog (discovery maintenance) ──
1038
+ /catalog/capabilities:
1039
+ get: { tags: [catalog], summary: List discovered capabilities, responses: { '200': { description: List } } }
1040
+
1041
+ /catalog/features:
1042
+ get: { tags: [catalog], summary: List feature catalog entries, responses: { '200': { description: List } } }
1043
+
1044
+ /catalog/quotas:
1045
+ get: { tags: [catalog], summary: List quota catalog entries, responses: { '200': { description: List } } }
1046
+
1047
+ /catalog/features/{key}:
1048
+ parameters: [{ name: key, in: path, required: true, schema: { type: string } }]
1049
+ patch:
1050
+ tags: [catalog]
1051
+ summary: Maintain a feature catalog entry (label/description/icon/tier)
1052
+ x-mfa-required: true
1053
+ requestBody:
1054
+ required: true
1055
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogEntryBaseUpdate' } } }
1056
+ responses: { '200': { description: Updated } }
1057
+
1058
+ /catalog/features/{key}/review:
1059
+ parameters: [{ name: key, in: path, required: true, schema: { type: string } }]
1060
+ patch:
1061
+ tags: [catalog]
1062
+ summary: Set the feature approval status
1063
+ x-mfa-required: true
1064
+ requestBody:
1065
+ required: true
1066
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogEntryReview' } } }
1067
+ responses: { '200': { description: Set } }
1068
+
1069
+ /catalog/features/{key}/i18n:
1070
+ parameters: [{ name: key, in: path, required: true, schema: { type: string } }]
1071
+ patch:
1072
+ tags: [catalog]
1073
+ summary: Set feature i18n (locale translations)
1074
+ x-mfa-required: true
1075
+ requestBody:
1076
+ required: true
1077
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogEntryI18nUpdate' } } }
1078
+ responses: { '200': { description: Set } }
1079
+
1080
+ /catalog/quotas/{key}:
1081
+ parameters: [{ name: key, in: path, required: true, schema: { type: string } }]
1082
+ patch:
1083
+ tags: [catalog]
1084
+ summary: Maintain a quota catalog entry (label/description)
1085
+ x-mfa-required: true
1086
+ requestBody:
1087
+ required: true
1088
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogEntryBaseUpdate' } } }
1089
+ responses: { '200': { description: Updated } }
1090
+
1091
+ /catalog/quotas/{key}/review:
1092
+ parameters: [{ name: key, in: path, required: true, schema: { type: string } }]
1093
+ patch:
1094
+ tags: [catalog]
1095
+ summary: Set the quota approval status
1096
+ x-mfa-required: true
1097
+ requestBody:
1098
+ required: true
1099
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogEntryReview' } } }
1100
+ responses: { '200': { description: Set } }
1101
+
1102
+ /catalog/quotas/{key}/i18n:
1103
+ parameters: [{ name: key, in: path, required: true, schema: { type: string } }]
1104
+ patch:
1105
+ tags: [catalog]
1106
+ summary: Set quota i18n
1107
+ x-mfa-required: true
1108
+ requestBody:
1109
+ required: true
1110
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogEntryI18nUpdate' } } }
1111
+ responses: { '200': { description: Set } }
1112
+
1113
+ /catalog/discovery/sync:
1114
+ post:
1115
+ tags: [catalog]
1116
+ summary: Synchronize a discovery snapshot into the catalog
1117
+ x-mfa-required: true
1118
+ requestBody:
1119
+ required: true
1120
+ content: { application/json: { schema: { $ref: '#/components/schemas/CatalogDiscoverySync' } } }
1121
+ responses: { '200': { description: Synchronized } }
1122
+
1123
+ # ── Discovery (scan/status, outside /catalog) ──
1124
+ /discovery:
1125
+ get: { tags: [catalog], summary: Current discovery snapshot/status, responses: { '200': { description: Snapshot } } }
1126
+
1127
+ /discovery/rescan:
1128
+ post: { tags: [catalog], summary: Re-run the discovery scan, x-mfa-required: true, responses: { '200': { description: Scanned } } }
1129
+
1130
+ components:
1131
+ securitySchemes:
1132
+ bearerAuth:
1133
+ type: http
1134
+ scheme: bearer
1135
+ bearerFormat: JWT
1136
+
1137
+ parameters:
1138
+ Page:
1139
+ name: page
1140
+ in: query
1141
+ schema: { type: integer, minimum: 1, default: 1 }
1142
+ PageSize:
1143
+ name: pageSize
1144
+ in: query
1145
+ schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
1146
+ TenantSlug:
1147
+ name: slug
1148
+ in: path
1149
+ required: true
1150
+ schema: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$' }
1151
+
1152
+ schemas:
1153
+ # ────────── Catalog V2 (DTO = SSOT: saas-platform-nest/src/catalog/dto/*) ──────────
1154
+ CatalogPlanCreate:
1155
+ type: object
1156
+ required: [projectKey, planKey, label]
1157
+ properties:
1158
+ projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$', description: 'kebab-case' }
1159
+ planKey:
1160
+ { type: string, pattern: '^[A-Z][A-Z0-9_]*$', description: 'SCREAMING_SNAKE, no hyphen' }
1161
+ label: { type: string, minLength: 1, maxLength: 120 }
1162
+ description: { type: string, maxLength: 2000 }
1163
+ icon: { type: string, maxLength: 64 }
1164
+ sortOrder: { type: integer, minimum: 0, maximum: 10000 }
1165
+
1166
+ CatalogPlanVersionDraft:
1167
+ type: object
1168
+ required: [features, quotas, monthlyNet, yearlyNet]
1169
+ properties:
1170
+ features:
1171
+ type: array
1172
+ items: { type: string, pattern: '^[A-Z][A-Z0-9_]*$' }
1173
+ bundles:
1174
+ type: array
1175
+ items: { type: string, pattern: '^[A-Z][A-Z0-9_]*$' }
1176
+ quotas:
1177
+ type: object
1178
+ additionalProperties: { type: number }
1179
+ description: 'camelCase keys, e.g. {"members": 150}'
1180
+ monthlyNet:
1181
+ { type: string, pattern: '^\d+(\.\d{1,2})?$', description: 'EUR decimal string, not cents' }
1182
+ yearlyNet: { type: string, pattern: '^\d+(\.\d{1,2})?$' }
1183
+ marketed: { type: boolean }
1184
+ changeNote: { type: string, maxLength: 2000 }
1185
+ baseVersionId: { type: string, nullable: true }
1186
+ validFrom: { type: string, nullable: true, description: 'YYYY-MM-DD or ISO-8601' }
1187
+ validUntil: { type: string, nullable: true }
1188
+
1189
+ CatalogBundleCreate:
1190
+ type: object
1191
+ required: [projectKey, bundleKey, label]
1192
+ properties:
1193
+ projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1194
+ bundleKey:
1195
+ { type: string, pattern: '^[A-Z][A-Z0-9_]*$', description: 'no hyphen' }
1196
+ label: { type: string, minLength: 1, maxLength: 120 }
1197
+ description: { type: string, maxLength: 2000 }
1198
+ icon: { type: string, maxLength: 64 }
1199
+ sortOrder: { type: integer, minimum: 0, maximum: 10000 }
1200
+ i18n: { type: object, additionalProperties: { type: object } }
1201
+
1202
+ CatalogBundleVersionDraft:
1203
+ type: object
1204
+ required: [features]
1205
+ properties:
1206
+ features:
1207
+ type: array
1208
+ items: { type: string, pattern: '^[A-Z][A-Z0-9_]*$' }
1209
+ quotas:
1210
+ type: object
1211
+ additionalProperties: { type: number }
1212
+ compatibility:
1213
+ type: object
1214
+ description: 'both empty/omitted = freely bookable everywhere (E5)'
1215
+ properties:
1216
+ businessTypeKeys: { type: array, items: { type: string } }
1217
+ planIds: { type: array, items: { type: string } }
1218
+ monthlyNet:
1219
+ { type: string, pattern: '^\d+(\.\d{1,2})?$', nullable: true, description: 'EUR string; null = override resolution' }
1220
+ yearlyNet: { type: string, pattern: '^\d+(\.\d{1,2})?$', nullable: true }
1221
+ marketed: { type: boolean }
1222
+ changeNote: { type: string, maxLength: 2000 }
1223
+ baseVersionId: { type: string, nullable: true }
1224
+ validFrom: { type: string, nullable: true }
1225
+ validUntil: { type: string, nullable: true }
1226
+
1227
+ CatalogVersionPublish:
1228
+ type: object
1229
+ description: 'Applies to plan-versions AND bundle-versions /publish'
1230
+ properties:
1231
+ forceRegressive: { type: boolean, description: 'Publish a regressive diff anyway (MFA)' }
1232
+ allowZeroPrice: { type: boolean, description: 'Allow publishing despite a price of 0.00 (special contracts only)' }
1233
+ validFrom: { type: string, nullable: true, description: 'Required if the draft carries no validFrom' }
1234
+ validUntil: { type: string, nullable: true }
1235
+
1236
+ CatalogMarketingProjectionCreate:
1237
+ type: object
1238
+ required: [projectKey, targetType, targetVersionId, displayLabel, description]
1239
+ properties:
1240
+ projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1241
+ targetType: { type: string, enum: [PLAN, BUNDLE, BUSINESS_TYPE] }
1242
+ targetVersionId: { type: string }
1243
+ locale: { type: string, pattern: '^[a-z]{2}(-[A-Z]{2})?$', default: de }
1244
+ displayLabel: { type: string, minLength: 1, maxLength: 120 }
1245
+ description: { type: string, minLength: 1, maxLength: 2000 }
1246
+ visible: { type: boolean }
1247
+ badge: { type: string, maxLength: 40 }
1248
+ topFeatures:
1249
+ type: array
1250
+ items:
1251
+ type: object
1252
+ required: [label, strong]
1253
+ properties:
1254
+ key: { type: string }
1255
+ label: { type: string, maxLength: 120 }
1256
+ strong: { type: string, maxLength: 80 }
1257
+ trialEnabled: { type: boolean }
1258
+ trialDays: { type: integer, minimum: 1, maximum: 365 }
1259
+ priceTag: { type: string, maxLength: 120 }
1260
+ ctaLabel: { type: string, maxLength: 120 }
1261
+ priority: { type: integer, minimum: 0, maximum: 10000 }
1262
+ highlight: { type: boolean }
1263
+
1264
+ CatalogStampUpdate:
1265
+ type: object
1266
+ description: 'Root update for plan OR business type'
1267
+ properties:
1268
+ label: { type: string, minLength: 1, maxLength: 120 }
1269
+ description: { type: string, maxLength: 2000, nullable: true }
1270
+ icon: { type: string, maxLength: 64, nullable: true }
1271
+ sortOrder: { type: integer, minimum: 0, maximum: 10000 }
1272
+
1273
+ CatalogBundleUpdate:
1274
+ type: object
1275
+ properties:
1276
+ label: { type: string, minLength: 1, maxLength: 120 }
1277
+ description: { type: string, maxLength: 2000, nullable: true }
1278
+ icon: { type: string, maxLength: 64, nullable: true }
1279
+ sortOrder: { type: integer, minimum: 0, maximum: 10000 }
1280
+ i18n: { type: object, additionalProperties: { type: object } }
1281
+
1282
+ CatalogPlanVersionTerminate:
1283
+ type: object
1284
+ required: [endsAt]
1285
+ properties:
1286
+ endsAt: { type: string, description: 'ISO-8601; must lie in the future' }
1287
+
1288
+ CatalogMarketingProjectionUpdate:
1289
+ type: object
1290
+ description: 'Partial update of a marketing projection'
1291
+ properties:
1292
+ displayLabel: { type: string, minLength: 1, maxLength: 120 }
1293
+ description: { type: string, minLength: 1, maxLength: 2000 }
1294
+ visible: { type: boolean }
1295
+ badge: { type: string, maxLength: 40 }
1296
+ topFeatures:
1297
+ type: array
1298
+ items:
1299
+ type: object
1300
+ required: [label, strong]
1301
+ properties:
1302
+ key: { type: string, maxLength: 120 }
1303
+ label: { type: string, maxLength: 120 }
1304
+ strong: { type: string, maxLength: 80 }
1305
+ trialEnabled: { type: boolean }
1306
+ trialDays: { type: integer, minimum: 1, maximum: 365 }
1307
+ priceTag: { type: string, maxLength: 120, nullable: true }
1308
+ ctaLabel: { type: string, maxLength: 120, nullable: true }
1309
+ priority: { type: integer, minimum: 0, maximum: 10000 }
1310
+ highlight: { type: boolean }
1311
+
1312
+ CatalogBusinessTypeCreate:
1313
+ type: object
1314
+ required: [projectKey, businessTypeKey, label]
1315
+ properties:
1316
+ projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1317
+ businessTypeKey: { type: string, pattern: '^[A-Z][A-Z0-9_]*$', description: 'SCREAMING_SNAKE' }
1318
+ label: { type: string, minLength: 1, maxLength: 120 }
1319
+ description: { type: string, maxLength: 2000 }
1320
+ icon: { type: string, maxLength: 64 }
1321
+ sortOrder: { type: integer, minimum: 0, maximum: 10000 }
1322
+
1323
+ CatalogBusinessTypeVersionDraft:
1324
+ type: object
1325
+ required: [bundles]
1326
+ properties:
1327
+ bundles:
1328
+ type: array
1329
+ minItems: 1
1330
+ items:
1331
+ type: object
1332
+ required: [bundleVersionId]
1333
+ properties:
1334
+ bundleVersionId: { type: string, format: uuid }
1335
+ sortOrder: { type: integer, minimum: 0, maximum: 10000 }
1336
+ quotaOverrides: { type: object, additionalProperties: { type: number } }
1337
+ monthlyNet: { type: string, pattern: '^\d+(\.\d{1,2})?$', nullable: true }
1338
+ yearlyNet: { type: string, pattern: '^\d+(\.\d{1,2})?$', nullable: true }
1339
+ marketed: { type: boolean }
1340
+ changeNote: { type: string, maxLength: 2000 }
1341
+ baseVersionId: { type: string, format: uuid, nullable: true }
1342
+
1343
+ CatalogBusinessTypeVersionPublish:
1344
+ type: object
1345
+ properties:
1346
+ forceRegressive: { type: boolean }
1347
+
1348
+ CatalogEntryReview:
1349
+ type: object
1350
+ required: [discoveryStatus]
1351
+ properties:
1352
+ discoveryStatus: { type: string, enum: [pending, approved, outdated, obsolete] }
1353
+
1354
+ CatalogEntryI18nUpdate:
1355
+ type: object
1356
+ required: [i18n]
1357
+ properties:
1358
+ i18n:
1359
+ type: object
1360
+ additionalProperties:
1361
+ type: object
1362
+ properties:
1363
+ label: { type: string }
1364
+ description: { type: string }
1365
+ description: 'Locale map → {label?, description?}'
1366
+
1367
+ CatalogEntryBaseUpdate:
1368
+ type: object
1369
+ properties:
1370
+ label: { type: string, maxLength: 120 }
1371
+ description: { type: string, maxLength: 2000, nullable: true }
1372
+ icon: { type: string, maxLength: 64, nullable: true, description: 'feature only' }
1373
+ tier: { type: string, maxLength: 40, nullable: true, description: 'feature only, logical group' }
1374
+
1375
+ CatalogDiscoverySync:
1376
+ type: object
1377
+ required: [snapshot]
1378
+ properties:
1379
+ snapshot:
1380
+ type: object
1381
+ additionalProperties: true
1382
+ description: 'DiscoverySnapshot (app/features/quotas/capabilities) from @saasicat/types'
1383
+
1384
+ CatalogMarketingSettingsUpdate:
1385
+ type: object
1386
+ required: [projectKey, activeLocales]
1387
+ properties:
1388
+ projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1389
+ activeLocales:
1390
+ type: array
1391
+ uniqueItems: true
1392
+ items: { type: string, pattern: '^[a-z]{2}(-[A-Z]{2})?$' }
1393
+
1394
+ CatalogPromotionCreate:
1395
+ type: object
1396
+ required: [projectKey, internalLabel, type, value, validFrom, validTo]
1397
+ properties:
1398
+ projectKey: { type: string, pattern: '^[a-z][a-z0-9-]*$' }
1399
+ internalLabel: { type: string, maxLength: 120 }
1400
+ type: { type: string, enum: [percent, amount, intro, freeMonths] }
1401
+ value:
1402
+ description: 'type-dependent: number (percent/amount) OR {price, months} (intro/freeMonths)'
1403
+ oneOf:
1404
+ - { type: number }
1405
+ - { type: object }
1406
+ appliesTo: { type: array, items: { type: string } }
1407
+ targetType: { type: string, enum: [PLAN, BUNDLE, OFFER] }
1408
+ billingCycle: { type: string, enum: [monthly, yearly, both] }
1409
+ validFrom: { type: string, description: 'ISO-8601' }
1410
+ validTo: { type: string, description: 'ISO-8601' }
1411
+ priority: { type: integer }
1412
+ onlyLocales: { type: array, items: { type: string }, nullable: true }
1413
+ requiresCoupon: { type: boolean }
1414
+ codes: { type: array, items: { type: string } }
1415
+ color: { type: string, maxLength: 16 }
1416
+ i18n: { type: object, additionalProperties: true }
1417
+
1418
+ CatalogPromotionUpdate:
1419
+ type: object
1420
+ description: 'Partial update of a promotion (all fields optional)'
1421
+ properties:
1422
+ internalLabel: { type: string, maxLength: 120 }
1423
+ type: { type: string, enum: [percent, amount, intro, freeMonths] }
1424
+ value: { oneOf: [{ type: number }, { type: object }] }
1425
+ appliesTo: { type: array, items: { type: string } }
1426
+ targetType: { type: string, enum: [PLAN, BUNDLE, OFFER] }
1427
+ billingCycle: { type: string, enum: [monthly, yearly, both] }
1428
+ validFrom: { type: string }
1429
+ validTo: { type: string }
1430
+ priority: { type: integer }
1431
+ onlyLocales: { type: array, items: { type: string }, nullable: true }
1432
+ requiresCoupon: { type: boolean }
1433
+ codes: { type: array, items: { type: string } }
1434
+ color: { type: string, maxLength: 16 }
1435
+ i18n: { type: object, additionalProperties: true }
1436
+
1437
+ # ────────── Common ──────────
1438
+ PaginatedEnvelope:
1439
+ type: object
1440
+ required: [page, pageSize, total]
1441
+ properties:
1442
+ page: { type: integer }
1443
+ pageSize: { type: integer }
1444
+ total: { type: integer }
1445
+
1446
+ ErrorResponse:
1447
+ type: object
1448
+ required: [code, message]
1449
+ properties:
1450
+ code:
1451
+ type: string
1452
+ enum:
1453
+ - LIMIT_EXCEEDED
1454
+ - FEATURE_NOT_INCLUDED
1455
+ - MFA_REQUIRED
1456
+ - PROMO_NOT_FOUND
1457
+ - PROMO_EXPIRED
1458
+ - PROMO_EXHAUSTED
1459
+ - PROMO_NOT_APPLICABLE
1460
+ - PROMO_FIRST_TIME_ONLY
1461
+ - PROMO_ZERO_INVOICE_BLOCKED
1462
+ - VALIDATION_FAILED
1463
+ - FORBIDDEN
1464
+ - NOT_FOUND
1465
+ message: { type: string }
1466
+ details: { type: object, additionalProperties: true }
1467
+
1468
+ # ────────── Plan-Catalog (mirror of plans.yaml) ──────────
1469
+ PlanCatalog:
1470
+ type: object
1471
+ required: [schemaVersion, projectKey, currency, vatRate, plans]
1472
+ properties:
1473
+ schemaVersion: { type: integer, const: 1 }
1474
+ projectKey: { type: string, description: 'Project key of the app (kebab-case)' }
1475
+ currency: { type: string, pattern: '^[A-Z]{3}$' }
1476
+ vatRate: { type: number, format: decimal, description: 'in percent' }
1477
+ features:
1478
+ type: array
1479
+ items:
1480
+ type: object
1481
+ required: [key]
1482
+ properties:
1483
+ key: { type: string }
1484
+ label: { type: string }
1485
+ tier:
1486
+ type: string
1487
+ description: 'Optional logical group — e.g. CORE / ADVANCED / PRO'
1488
+ plans:
1489
+ type: array
1490
+ items: { $ref: '#/components/schemas/PlanDef' }
1491
+
1492
+ PlanDef:
1493
+ type: object
1494
+ required: [id, quotas, features]
1495
+ properties:
1496
+ id: { type: string }
1497
+ name: { type: string }
1498
+ tagline: { type: string }
1499
+ marketed: { type: boolean, default: true }
1500
+ popular: { type: boolean, default: false }
1501
+ monthlyNet: { type: number, format: decimal, nullable: true }
1502
+ yearlyNet: { type: number, format: decimal, nullable: true }
1503
+ quotas:
1504
+ type: object
1505
+ additionalProperties:
1506
+ type: integer
1507
+ description: '-1 = unlimited'
1508
+ features:
1509
+ type: array
1510
+ items: { type: string }
1511
+
1512
+ # ────────── Tenant ──────────
1513
+ AdminTenantRow:
1514
+ type: object
1515
+ properties:
1516
+ id: { type: string }
1517
+ slug: { type: string }
1518
+ name: { type: string }
1519
+ plan: { type: string }
1520
+ status: { type: string }
1521
+ createdAt: { type: string, format: date-time }
1522
+ usage:
1523
+ type: object
1524
+ additionalProperties: { type: integer }
1525
+ description: 'Current usage per quotaKey'
1526
+
1527
+ AdminTenantDetail:
1528
+ allOf:
1529
+ - $ref: '#/components/schemas/AdminTenantRow'
1530
+ - type: object
1531
+ properties:
1532
+ email: { type: string, format: email }
1533
+ taxId: { type: string }
1534
+ vatId: { type: string }
1535
+ address: { type: object }
1536
+ settings: { type: object }
1537
+ subscription: { $ref: '#/components/schemas/SubscriptionDetail' }
1538
+ recentAudit:
1539
+ type: array
1540
+ items: { $ref: '#/components/schemas/AuditEntryDto' }
1541
+
1542
+ CreateTenantRequest:
1543
+ type: object
1544
+ required: [slug, name, adminEmail, adminFirstName, adminLastName]
1545
+ properties:
1546
+ slug: { type: string }
1547
+ name: { type: string }
1548
+ adminEmail: { type: string, format: email }
1549
+ adminFirstName: { type: string }
1550
+ adminLastName: { type: string }
1551
+ planId:
1552
+ { type: string, description: 'From PlanCatalog. Default: first marketed plan.' }
1553
+ billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
1554
+ isPilot: { type: boolean, default: false }
1555
+ pilotEndsAt: { type: string, format: date-time }
1556
+ sendWelcomeMail: { type: boolean, default: true }
1557
+
1558
+ # ────────── Subscription ──────────
1559
+ SubscriptionDetail:
1560
+ type: object
1561
+ properties:
1562
+ id: { type: string }
1563
+ planId: { type: string }
1564
+ billingCycle: { type: string }
1565
+ status: { type: string }
1566
+ trialEndsAt: { type: string, format: date-time }
1567
+ startedAt: { type: string, format: date-time }
1568
+ canceledAt: { type: string, format: date-time }
1569
+ isPilot: { type: boolean }
1570
+ pilotEndsAt: { type: string, format: date-time }
1571
+ customLimits:
1572
+ type: object
1573
+ additionalProperties: { type: integer }
1574
+ customMonthlyNet: { type: number, format: decimal, nullable: true }
1575
+ effectiveLimits:
1576
+ type: object
1577
+ additionalProperties: { type: integer }
1578
+ description: 'Plan quotas + bundle contributions + customLimits overrides'
1579
+ effectiveFeatures:
1580
+ type: array
1581
+ items: { type: string }
1582
+ promoRedemption:
1583
+ $ref: '#/components/schemas/PromoRedemptionDto'
1584
+
1585
+ # ────────── User ──────────
1586
+ AdminUserRow:
1587
+ type: object
1588
+ properties:
1589
+ id: { type: string }
1590
+ email: { type: string, format: email }
1591
+ firstName: { type: string }
1592
+ lastName: { type: string }
1593
+ platformRole: { type: string, enum: [SUPER_ADMIN, TENANT_ADMIN, TENANT_MEMBER] }
1594
+ isActive: { type: boolean }
1595
+ lastLoginAt: { type: string, format: date-time, nullable: true }
1596
+ tenants:
1597
+ type: array
1598
+ items:
1599
+ type: object
1600
+ properties:
1601
+ id: { type: string }
1602
+ slug: { type: string }
1603
+
1604
+ # ────────── Promo-Code ──────────
1605
+ CreatePromoCodeRequest:
1606
+ type: object
1607
+ required: [code, valueType, value, durationType]
1608
+ properties:
1609
+ code: { type: string, pattern: '^[A-Z0-9_-]{3,32}$' }
1610
+ valueType: { type: string, enum: [PERCENT, ABSOLUTE] }
1611
+ value: { type: number, format: decimal, minimum: 0.01 }
1612
+ durationType: { type: string, enum: [ONCE, MONTHS, BILLING_CYCLES] }
1613
+ durationValue: { type: integer, minimum: 1, maximum: 24 }
1614
+ validFrom: { type: string, format: date-time }
1615
+ validUntil: { type: string, format: date-time }
1616
+ maxRedemptions: { type: integer, minimum: 1 }
1617
+ appliesToPlans:
1618
+ type: array
1619
+ items: { type: string }
1620
+ appliesToBilling: { type: string, enum: [MONTHLY, YEARLY] }
1621
+ firstTimeCustomersOnly: { type: boolean, default: true }
1622
+ minimumPlanAmountGross: { type: number, format: decimal }
1623
+ allowZeroInvoice: { type: boolean, default: false }
1624
+ description: { type: string }
1625
+ campaignTag: { type: string }
1626
+
1627
+ UpdatePromoCodeRequest:
1628
+ type: object
1629
+ properties:
1630
+ status: { type: string, enum: [ACTIVE, PAUSED] }
1631
+ validUntil: { type: string, format: date-time }
1632
+ maxRedemptions: { type: integer }
1633
+ description: { type: string }
1634
+ campaignTag: { type: string }
1635
+
1636
+ PromoCodeDto:
1637
+ type: object
1638
+ properties:
1639
+ id: { type: string }
1640
+ code: { type: string }
1641
+ valueType: { type: string }
1642
+ value: { type: number, format: decimal }
1643
+ durationType: { type: string }
1644
+ durationValue: { type: integer, nullable: true }
1645
+ validFrom: { type: string, format: date-time, nullable: true }
1646
+ validUntil: { type: string, format: date-time, nullable: true }
1647
+ maxRedemptions: { type: integer, nullable: true }
1648
+ redemptionsCount: { type: integer }
1649
+ appliesToPlans:
1650
+ type: array
1651
+ items: { type: string }
1652
+ appliesToBilling: { type: string, nullable: true }
1653
+ firstTimeCustomersOnly: { type: boolean }
1654
+ status: { type: string }
1655
+ campaignTag: { type: string, nullable: true }
1656
+ createdAt: { type: string, format: date-time }
1657
+ deletedAt: { type: string, format: date-time, nullable: true }
1658
+
1659
+ PromoCodeDetail:
1660
+ allOf:
1661
+ - $ref: '#/components/schemas/PromoCodeDto'
1662
+ - type: object
1663
+ properties:
1664
+ recentRedemptions:
1665
+ type: array
1666
+ items: { $ref: '#/components/schemas/PromoRedemptionDto' }
1667
+ recentValidations:
1668
+ type: array
1669
+ items:
1670
+ type: object
1671
+ properties:
1672
+ result: { type: string }
1673
+ createdAt: { type: string, format: date-time }
1674
+ ipHash: { type: string, nullable: true }
1675
+
1676
+ PromoRedemptionDto:
1677
+ type: object
1678
+ properties:
1679
+ id: { type: string }
1680
+ promoCodeId: { type: string }
1681
+ promoCode: { type: string, description: 'Convenience: the code string' }
1682
+ subscriptionId: { type: string }
1683
+ tenantSlug: { type: string }
1684
+ appliedValueType: { type: string }
1685
+ appliedValue: { type: number, format: decimal }
1686
+ startsAt: { type: string, format: date-time }
1687
+ endsAt: { type: string, format: date-time, nullable: true }
1688
+ status: { type: string }
1689
+ redeemedAt: { type: string, format: date-time }
1690
+ reversedAt: { type: string, format: date-time, nullable: true }
1691
+
1692
+ # ────────── Audit ──────────
1693
+ AuditEntryDto:
1694
+ type: object
1695
+ properties:
1696
+ id: { type: string }
1697
+ tenantId: { type: string, nullable: true }
1698
+ userId: { type: string, nullable: true }
1699
+ userEmail: { type: string, nullable: true, description: 'Convenience lookup' }
1700
+ entity: { type: string }
1701
+ entityId: { type: string }
1702
+ action: { type: string }
1703
+ changes: { type: object, additionalProperties: true }
1704
+ actorTag: { type: string, nullable: true }
1705
+ ipAddress: { type: string, nullable: true }
1706
+ createdAt: { type: string, format: date-time }
1707
+
1708
+ # ────────── Dashboard ──────────
1709
+ DashboardStats:
1710
+ type: object
1711
+ properties:
1712
+ tenantsTotal: { type: integer }
1713
+ tenantsByStatus:
1714
+ type: object
1715
+ additionalProperties: { type: integer }
1716
+ subscriptionsByPlan:
1717
+ type: object
1718
+ additionalProperties: { type: integer }
1719
+ pilotsActive: { type: integer }
1720
+ promoCodesActive: { type: integer }
1721
+ promoRedemptionsLast30d: { type: integer }
1722
+ auditEventsLast24h: { type: integer }
1723
+ # Project-specific KPIs are delivered via separate endpoints
1724
+ # (e.g. /admin/dashboard/extras/datev — remains app-specific).