@saasicat/core 1.0.0-rc.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.
@@ -0,0 +1,4809 @@
1
+ /** Start of day (00:00 UTC) of the moment — for day-inclusive date comparisons. */
2
+ declare function startOfUtcDay(date: Date): Date;
3
+ /** `<=` upper bound for a date (structurally Prisma-compatible). */
4
+ interface DateAtOrBefore {
5
+ lte: Date;
6
+ }
7
+ /** `>=` lower bound (day-inclusive) for a date (structurally Prisma-compatible). */
8
+ interface DateAtOrAfter {
9
+ gte: Date;
10
+ }
11
+ /** `>` upper bound (exclusive, timestamp) for a date. */
12
+ interface DateAfter {
13
+ gt: Date;
14
+ }
15
+ /** `OR` block of the validity window; exactly one date field per variant. */
16
+ type ValidityWindowClause = {
17
+ validFrom: DateAtOrBefore | null;
18
+ } | {
19
+ validUntil: DateAtOrAfter | null;
20
+ };
21
+ /** Optional `OR` block for models with `endsAt` (precise admin termination). */
22
+ type EndsAtClause = {
23
+ endsAt: DateAfter | null;
24
+ };
25
+ /**
26
+ * Structural counterpart to the `*PlanVersionWhereInput` excerpt for models
27
+ * without `endsAt` (e.g. `CatalogPlanVersion`).
28
+ */
29
+ interface ActivePlanVersionWhere {
30
+ publishedAt: {
31
+ not: null;
32
+ };
33
+ AND: Array<{
34
+ OR: ValidityWindowClause[];
35
+ }>;
36
+ }
37
+ /** Like {@link ActivePlanVersionWhere}, additionally with `endsAt` clause. */
38
+ interface ActivePlanVersionWhereWithEndsAt {
39
+ publishedAt: {
40
+ not: null;
41
+ };
42
+ AND: Array<{
43
+ OR: Array<ValidityWindowClause | EndsAtClause>;
44
+ }>;
45
+ }
46
+ /**
47
+ * Builds the time-window WHERE for the PlanVersion active at `asOf`:
48
+ * `publishedAt IS NOT NULL`
49
+ * `(validFrom IS NULL OR validFrom <= asOf)`
50
+ * `(validUntil IS NULL OR validUntil >= startOfUtcDay(asOf))` // day-inclusive
51
+ * with `withEndsAt`: additionally `(endsAt IS NULL OR endsAt > asOf)`. // precise
52
+ *
53
+ * `planId` stays with the caller (repo-specific type). Matching Prisma
54
+ * `orderBy` for PostgreSQL:
55
+ * `[{ validFrom: { sort: 'desc', nulls: 'last' } }, { version: 'desc' }]`.
56
+ */
57
+ declare function buildActivePlanVersionWhere(asOf: Date, options?: {
58
+ withEndsAt?: false;
59
+ }): ActivePlanVersionWhere;
60
+ declare function buildActivePlanVersionWhere(asOf: Date, options: {
61
+ withEndsAt: true;
62
+ }): ActivePlanVersionWhereWithEndsAt;
63
+ /**
64
+ * Model-neutral name for {@link ActivePlanVersionWhere}. Bundle- and
65
+ * PlanVersion repositories share the same published validity-window rules.
66
+ */
67
+ type ActiveVersionWhere = ActivePlanVersionWhere;
68
+ /** Model-neutral counterpart that additionally checks an `endsAt` timestamp. */
69
+ type ActiveVersionWhereWithEndsAt = ActivePlanVersionWhereWithEndsAt;
70
+ /**
71
+ * Model-neutral alias for {@link buildActivePlanVersionWhere}. The original
72
+ * export remains available for backwards compatibility.
73
+ */
74
+ declare const buildActiveVersionWhere: typeof buildActivePlanVersionWhere;
75
+
76
+ type FeatureKey = string;
77
+ type PlanId = string;
78
+ type QuotaKey = string;
79
+ interface FeatureDef {
80
+ key: FeatureKey;
81
+ label?: string;
82
+ icon?: string;
83
+ /** CORE / ADVANCED / PRO / BUSINESS / ENTERPRISE_ONLY — convention. */
84
+ tier?: string;
85
+ plannedOnly?: boolean;
86
+ }
87
+ interface PlanDef {
88
+ id: PlanId;
89
+ name?: string;
90
+ tagline?: string;
91
+ /** false = not selectable in self-service onboarding. Default: true. */
92
+ marketed?: boolean;
93
+ /** Highlighted card in onboarding (max. 1 per catalog). */
94
+ popular?: boolean;
95
+ /** Net monthly price. null = on request. */
96
+ monthlyNet?: number | null;
97
+ /** Net total amount per year. null = monthly only. */
98
+ yearlyNet?: number | null;
99
+ /** Map quotaKey → max value. -1 = unlimited. */
100
+ quotas: Record<QuotaKey, number>;
101
+ features: FeatureKey[];
102
+ }
103
+ /** App-wide marketing configuration. */
104
+ interface PlanCatalogMarketing {
105
+ /**
106
+ * Allowed language pool that the app may market. First = default
107
+ * locale. From it, the SuperAdmin activates a subset in the marketing
108
+ * catalog (LocaleManager).
109
+ */
110
+ availableLocales: string[];
111
+ }
112
+ /**
113
+ * App identity block for branding + version. Consumed by the `AdminPublicBootController`
114
+ * and the `AdminManifestConfigFactory`; the SuperAdmin UI (platform
115
+ * LoginPage, AdminLayout brand block) reads the same fields via PublicBoot.
116
+ *
117
+ * `name` = brand display name (e.g. "DemoApp", "ClubApp").
118
+ * `label` = tag/subtitle in the brand block (e.g. "SuperAdmin").
119
+ * `version` = app version string (build info).
120
+ * `icon` = 2-character abbreviation for the logo badge (e.g. "ma", "da").
121
+ * `logoUrl` = optional URL to a PNG/SVG; if set, the UI renders an <img>
122
+ * instead of the initials badge.
123
+ */
124
+ interface PlanCatalogApp {
125
+ name: string;
126
+ label?: string;
127
+ version?: string;
128
+ icon?: string;
129
+ logoUrl?: string;
130
+ }
131
+ interface PlanCatalog {
132
+ schemaVersion: 1;
133
+ projectKey: string;
134
+ /** App identity (branding + version), see PlanCatalogApp. Optional. */
135
+ app?: PlanCatalogApp;
136
+ /** ISO-4217 currency code. */
137
+ currency: string;
138
+ /** VAT rate in percent. */
139
+ vatRate: number;
140
+ /** App-wide marketing configuration. Optional. */
141
+ marketing?: PlanCatalogMarketing;
142
+ features?: FeatureDef[];
143
+ /**
144
+ * Optional. When omitted, plans come exclusively from the
145
+ * AdminUI / DB table (Plans/PlanVersions lifecycle).
146
+ */
147
+ plans?: PlanDef[];
148
+ }
149
+
150
+ /** Backend capability key, convention: domain.action[.action]. */
151
+ type CapabilityKey = string;
152
+ /** Frontend action-registry key. Same convention as CapabilityKey. */
153
+ type ActionKey = CapabilityKey;
154
+ /** Lookup key in the static extensions: map of the UI build. */
155
+ type ComponentKey = string;
156
+ interface AdminManifest {
157
+ schemaVersion: 1;
158
+ project: {
159
+ key: string;
160
+ displayName: string;
161
+ /** Tag/subtitle (e.g. "SuperAdmin"). From `saas.yaml#app.label`. */
162
+ label?: string;
163
+ /** Short abbreviation for the logo badge (e.g. "ma", "da"). From `saas.yaml#app.icon`. */
164
+ icon?: string;
165
+ logoUrl?: string;
166
+ environment?: 'production' | 'staging' | 'development';
167
+ /**
168
+ * Allowed locale pool from the app config (`saas.yaml`
169
+ * `marketing.availableLocales`). First = default..
170
+ */
171
+ availableLocales?: string[];
172
+ /** Default locale; equals `availableLocales[0]`. */
173
+ defaultLocale?: string;
174
+ };
175
+ build: {
176
+ platformPackageVersion: string;
177
+ appVersion: string;
178
+ manifestHash: string;
179
+ };
180
+ planCatalogSnapshot: {
181
+ source: string;
182
+ hash: string;
183
+ currency: string;
184
+ vatRate: number;
185
+ features?: FeatureDef[];
186
+ plans: PlanDef[];
187
+ };
188
+ /** Map CapabilityKey → boolean. Manifest is never a security source. */
189
+ capabilities: Record<CapabilityKey, boolean>;
190
+ navigation: {
191
+ standardPages: Partial<Record<StandardPageKey, StandardPageDef>>;
192
+ projectPages?: ProjectPageDef[];
193
+ };
194
+ dashboard?: {
195
+ kpiCards?: KpiCardDef[];
196
+ };
197
+ tenants?: {
198
+ columns?: TenantColumnDef[];
199
+ actions?: TenantActionDef[];
200
+ };
201
+ audit?: {
202
+ actions?: AuditActionDef[];
203
+ };
204
+ }
205
+ type StandardPageKey = 'dashboard' | 'tenants' | 'subscriptions' | 'promoCodes' | 'plans' | 'audit' | 'users' | 'pilots' | 'discovery' | 'bundles' | 'marketingCatalog' | 'platformEmail' | 'platformEmailHistory';
206
+ interface StandardPageDef {
207
+ enabled: boolean;
208
+ requiredCapability?: CapabilityKey;
209
+ }
210
+ interface ProjectPageDef {
211
+ /** `<projectKey>.<area>`, e.g. `demoapp.datev`. */
212
+ id: string;
213
+ label: string;
214
+ icon?: string;
215
+ /** Frontend route, e.g. `/admin/datev`. */
216
+ route: string;
217
+ navSection?: string;
218
+ /** Lookup in the static extensions: map of the shell build. */
219
+ componentKey: ComponentKey;
220
+ requiredCapability?: CapabilityKey;
221
+ prefetchOnIdle?: boolean;
222
+ }
223
+ interface KpiCardDef {
224
+ id: string;
225
+ label: string;
226
+ /** Required path: /api/v1/admin/(extras|dashboard)/... */
227
+ endpoint: string;
228
+ displayHint: KpiDisplayHint;
229
+ /** 0–100; UI sorts descending. */
230
+ slotPriority?: number;
231
+ requiredCapability?: CapabilityKey;
232
+ }
233
+ interface KpiDisplayHint {
234
+ type: 'value' | 'value+timestamp' | 'value+spark8w' | 'value+delta';
235
+ icon?: string;
236
+ }
237
+ interface TenantColumnDef {
238
+ key: string;
239
+ label: string;
240
+ /** Required path: /api/v1/admin/extras/...; MUST be batch-capable, no {slug}/{tenantId}. */
241
+ endpoint: string;
242
+ requiredCapability?: CapabilityKey;
243
+ }
244
+ interface TenantActionDef {
245
+ /** `<projectKey>.<area>.<verb>`, e.g. `demoapp.datev.runExport`. */
246
+ id: string;
247
+ label: string;
248
+ /** Lookup in the static actions: map of the shell build. */
249
+ actionKey: ActionKey;
250
+ requiredCapability?: CapabilityKey;
251
+ requiresMfa?: boolean;
252
+ confirmType?: 'none' | 'simple' | 'typed-slug' | 'typed-production' | 'date';
253
+ }
254
+ interface AuditActionDef {
255
+ /** SCREAMING_SNAKE_CASE; matched to the AuditLog.action column. */
256
+ key: string;
257
+ label: string;
258
+ severity?: 'info' | 'low' | 'medium' | 'high';
259
+ }
260
+ interface ManifestContribution {
261
+ capabilities?: Record<CapabilityKey, boolean>;
262
+ navigation?: {
263
+ standardPages?: Partial<Record<StandardPageKey, StandardPageDef>>;
264
+ projectPages?: ProjectPageDef[];
265
+ };
266
+ dashboard?: {
267
+ kpiCards?: KpiCardDef[];
268
+ };
269
+ tenants?: {
270
+ columns?: TenantColumnDef[];
271
+ actions?: TenantActionDef[];
272
+ };
273
+ audit?: {
274
+ actions?: AuditActionDef[];
275
+ };
276
+ }
277
+ interface PublicBootResponse {
278
+ project: {
279
+ key: string;
280
+ displayName: string;
281
+ /** Tag/subtitle (e.g. "SuperAdmin"). From `saas.yaml#app.label`. */
282
+ label?: string;
283
+ /** Short abbreviation for the logo badge (e.g. "ma", "da"). From `saas.yaml#app.icon`. */
284
+ icon?: string;
285
+ logoUrl?: string;
286
+ environment?: 'production' | 'staging' | 'development';
287
+ };
288
+ }
289
+
290
+ /** Format: 'web:<email>:<sessionId>' or 'cli:<email>:<host>'. */
291
+ type ActorTag = string;
292
+ interface AuditEntry {
293
+ id: string;
294
+ /** null = platform action without tenant context (SUPER_ADMIN). */
295
+ tenantId: string | null;
296
+ /** null = system / cron-triggered. */
297
+ userId: string | null;
298
+ /** Convenience field; backend resolves it from userId. */
299
+ userEmail: string | null;
300
+ /** e.g. 'Tenant', 'PromoCode', 'Subscription', 'PlanVersion', 'User'. */
301
+ entity: string;
302
+ entityId: string;
303
+ /** SCREAMING_SNAKE_CASE; past-tense oriented. */
304
+ action: string;
305
+ /** Freely structured. Convention: { field: { old, new } } or { reason, ... }. */
306
+ changes: Record<string, unknown> | null;
307
+ actorTag: ActorTag | null;
308
+ ipAddress: string | null;
309
+ userAgent: string | null;
310
+ createdAt: string;
311
+ }
312
+ interface AuditQuery {
313
+ tenantId?: string;
314
+ userId?: string;
315
+ entity?: string;
316
+ entityId?: string;
317
+ action?: string;
318
+ /** Wildcard-capable, e.g. 'cli:*'. */
319
+ actorTag?: string;
320
+ from?: string;
321
+ to?: string;
322
+ page?: number;
323
+ pageSize?: number;
324
+ }
325
+
326
+ /**
327
+ * Approval lifecycle of a feature or a quota. Approval happens per
328
+ * FEATURE/QUOTA — not per capability (#20); only `approved` entries
329
+ * are sellable (gate in strict-mode-check/seed-gate/preflight).
330
+ *
331
+ * - `pending` — found in code, not yet approved; not available for
332
+ * planning
333
+ * - `approved` — approved by the SuperAdmin for plans, bundles & marketing;
334
+ * the approval signature freezes the code state
335
+ * - `outdated` — drift: the implementation has changed since approval
336
+ * (approval signature ≠ current snapshot) or was manually
337
+ * marked stale — re-approve
338
+ * - `obsolete` — deprecated or removed from code; no longer use in new
339
+ * plans
340
+ *
341
+ * A "replaced by X" (#39) is deliberately NOT its own status value: the union
342
+ * is consumed exhaustively (review state machine as `Record<DiscoveryStatus, …>`,
343
+ * status badges in the AdminUI, status columns in the consumer DBs) — a new
344
+ * value would force lockstep migrations across all consumers. Instead:
345
+ * `obsolete` + `successorKey` as a successor pointer; old readers degrade
346
+ * gracefully (still see `obsolete`).
347
+ */
348
+ type DiscoveryStatus = 'pending' | 'approved' | 'outdated' | 'obsolete';
349
+ /**
350
+ * Code status of a capability — read-only code fact from the scan (#20):
351
+ * `active`/`experimental`/`deprecated` come from the decorator, `retired`
352
+ * is set by the sync when the capability has disappeared from the code.
353
+ * Capabilities no longer carry a review status; the business approval
354
+ * lives on the feature/the quota.
355
+ */
356
+ type CapabilityCodeStatus = 'active' | 'experimental' | 'deprecated' | 'retired';
357
+ /**
358
+ * Implementation kind of a capability — corresponds to `kind` in the
359
+ * `@ImplementsCapability(...)` decorator.
360
+ */
361
+ type CapabilityKind = 'endpoint' | 'service' | 'job' | 'event';
362
+ /**
363
+ * Enforcement mode of a quota:
364
+ * - `hard` — exceeding blocks at the business level (corresponds to policy `hardCap`)
365
+ * - `soft` — exceeding is only counted/warned
366
+ */
367
+ type QuotaEnforcementMode = 'hard' | 'soft';
368
+ /**
369
+ * Locale-specific translation fields of a catalog entry. Empty/missing
370
+ * fields fall back in the UI to the default locale (`de`). `unit` is only
371
+ * relevant for quotas.
372
+ */
373
+ interface CatalogEntryI18nFields {
374
+ label?: string;
375
+ description?: string;
376
+ unit?: string;
377
+ }
378
+ /** `{ 'en': { label, description }, 'tr': { … } }` — the default locale is intentionally absent. */
379
+ type CatalogEntryI18n = Record<string, CatalogEntryI18nFields>;
380
+ /**
381
+ * SuperAdmin projection of a code-declared capability. The business
382
+ * truth (exists / does not exist) stays in the code; this table holds
383
+ * the code status (read-only fact, #20) and denormalized aggregation
384
+ * shells for UI lookups. Approval lives on the feature/the quota.
385
+ */
386
+ interface CapabilityCatalogEntryRow {
387
+ id: string;
388
+ projectKey: string;
389
+ capabilityKey: string;
390
+ label: string;
391
+ description: string | null;
392
+ /** Aggregation shell from the decorator (denormalized for UI lookup). */
393
+ featureKey: string | null;
394
+ /** Aggregation shell from the decorator (denormalized). */
395
+ bundleKey: string | null;
396
+ codeStatus: CapabilityCodeStatus;
397
+ /** Code owner tag from the decorator (e.g. 'accounting'). */
398
+ owner: string | null;
399
+ kind: CapabilityKind;
400
+ /** Recommended when codeStatus = 'deprecated'. */
401
+ replacementKey: string | null;
402
+ deprecatedAt: string | null;
403
+ removalPlannedAt: string | null;
404
+ reason: string | null;
405
+ /** Locale translations (discovery translation tab). */
406
+ i18n: CatalogEntryI18n;
407
+ sortOrder: number;
408
+ createdAt: string;
409
+ updatedAt: string;
410
+ deletedAt: string | null;
411
+ }
412
+ /**
413
+ * Tier hint for comparison-matrix sorting. Convention:
414
+ * `CORE` < `ADVANCED` < `PRO` < `ENTERPRISE`. Apps may add further
415
+ * tiers; sorting then happens via `FeatureCatalogEntry.sortOrder`.
416
+ */
417
+ type FeatureTier = 'CORE' | 'ADVANCED' | 'PRO' | 'ENTERPRISE' | string;
418
+ /**
419
+ * SuperAdmin projection of a feature (aggregation of capabilities that declare
420
+ * `feature: 'XYZ'` in the decorator). The short marketing form lives here;
421
+ * locale-specific long texts in MarketingProjectionRow.
422
+ */
423
+ interface FeatureCatalogEntryRow {
424
+ id: string;
425
+ projectKey: string;
426
+ featureKey: string;
427
+ label: string;
428
+ description: string | null;
429
+ /** Short marketing label for sidebar / comparison matrix. */
430
+ marketingLabel: string | null;
431
+ /** Short marketing description. For long locale texts: MarketingProjectionRow. */
432
+ marketingDescription: string | null;
433
+ icon: string | null;
434
+ tier: FeatureTier | null;
435
+ discoveryStatus: DiscoveryStatus;
436
+ /** Code-discovered feature dependencies (#35) — empty list = none. */
437
+ requires: string[];
438
+ /** Old feature keys that this feature supersedes (#39) — empty list = none. */
439
+ replaces: string[];
440
+ /**
441
+ * Successor pointer (#39): set when this key has disappeared from the
442
+ * code AND another snapshot key claims it via `replaces` — "replaced by
443
+ * X = guided migration" instead of a bare `obsolete` (= deleted without
444
+ * replacement).
445
+ */
446
+ successorKey: string | null;
447
+ /** Timestamp of the last approval; `null` while never approved. */
448
+ approvedAt: string | null;
449
+ /** User ID of the approving SuperAdmin. */
450
+ approvedBy: string | null;
451
+ /**
452
+ * Signature of the capability set at approval time
453
+ * (`capabilityKey@codeStatus`, sorted, `|`-separated). The auto-sync
454
+ * compares it against the current snapshot — on divergence,
455
+ * `approved` → `outdated` (drift, #20).
456
+ */
457
+ approvedSignature: string | null;
458
+ /**
459
+ * `true` = the feature is planned in the SuperAdmin plan but not yet
460
+ * implemented in the code. The blocking strict-mode check
461
+ * rejects plan publish with `plannedOnly` features.
462
+ */
463
+ plannedOnly: boolean;
464
+ /** true = base/always included (not bookable per plan). */
465
+ core: boolean;
466
+ /** Locale translations (discovery translation tab). */
467
+ i18n: CatalogEntryI18n;
468
+ sortOrder: number;
469
+ createdAt: string;
470
+ updatedAt: string;
471
+ deletedAt: string | null;
472
+ }
473
+ /**
474
+ * SuperAdmin projection of a code-declared quota (`@DefinesQuota`).
475
+ * Carries the review status as well as deploy relevance: a hard quota
476
+ * without `usageProvider` is not deployable (Preflight).
477
+ */
478
+ interface QuotaCatalogEntryRow {
479
+ id: string;
480
+ projectKey: string;
481
+ quotaKey: string;
482
+ label: string;
483
+ description: string | null;
484
+ /** Display unit, e.g. `members`, `GB`, `/month`. */
485
+ unit: string;
486
+ /** Aggregation shell from the decorator (denormalized). */
487
+ featureKey: string | null;
488
+ /**
489
+ * Class that declares the quota via `@DefinesQuota` (= UsageProvider).
490
+ * `null` = the quota is referenced (`@EnforceQuota`) but provided by no
491
+ * class — deploy-blocking when `enforcementMode: 'hard'`.
492
+ */
493
+ usageProvider: string | null;
494
+ enforcementMode: QuotaEnforcementMode;
495
+ discoveryStatus: DiscoveryStatus;
496
+ /** Old quota keys that this quota supersedes (#39) — empty list = none. */
497
+ replaces: string[];
498
+ /** Successor pointer (#39), analogous to `FeatureCatalogEntryRow.successorKey`. */
499
+ successorKey: string | null;
500
+ /** Timestamp of the last approval; `null` while never approved. */
501
+ approvedAt: string | null;
502
+ /** User ID of the approving SuperAdmin. */
503
+ approvedBy: string | null;
504
+ /**
505
+ * Signature of the code-derived quota facts at approval time
506
+ * (`unit|enforcementMode|usageProvider|featureKey`). The auto-sync
507
+ * compares it against the current snapshot — on divergence,
508
+ * `approved` → `outdated` (drift, #20).
509
+ */
510
+ approvedSignature: string | null;
511
+ /** Locale translations (`label`, `unit`, `description`). */
512
+ i18n: CatalogEntryI18n;
513
+ sortOrder: number;
514
+ createdAt: string;
515
+ updatedAt: string;
516
+ deletedAt: string | null;
517
+ }
518
+ /**
519
+ * Filter for `CatalogEntryRepository.list*()`. `discoveryStatus` applies to
520
+ * features/quotas, `codeStatus` to capabilities — per list, only the
521
+ * matching field is relevant.
522
+ */
523
+ interface CatalogEntryFilter {
524
+ projectKey: string;
525
+ discoveryStatus?: DiscoveryStatus;
526
+ codeStatus?: CapabilityCodeStatus;
527
+ }
528
+ /**
529
+ * Body of `PATCH …/{features,quotas}/:key/review` — the target status of
530
+ * the approval state machine. The service validates the allowed transitions
531
+ * (`pending → approved/obsolete`, `approved → pending/outdated/obsolete`,
532
+ * `outdated → approved/pending/obsolete`, `obsolete → pending`).
533
+ */
534
+ interface ReviewCatalogEntryData {
535
+ discoveryStatus: DiscoveryStatus;
536
+ }
537
+ /**
538
+ * Approved gate (#20 Slice 5): the sets of approved feature/quota keys
539
+ * (`discoveryStatus = 'approved'`) from the catalog entries. Strict-mode
540
+ * check, Seed-Gate and Preflight use them to enforce "only approved is
541
+ * sellable" — `null`/omitted skips the approval part (e.g. when no
542
+ * CatalogEntryRepository is registered).
543
+ */
544
+ interface ApprovedCatalogKeys {
545
+ features: ReadonlySet<string>;
546
+ quotas: ReadonlySet<string>;
547
+ }
548
+ /** Body of `PATCH …/{features,quotas}/:key/i18n`. */
549
+ interface UpdateCatalogEntryI18nData {
550
+ /** Complete i18n tree — replaces the existing one. */
551
+ i18n: CatalogEntryI18n;
552
+ }
553
+ /**
554
+ * Body of `PATCH …/{features,quotas}/:key` — editable base/default locale
555
+ * fields (`de`). Quotas: `unit` stays code-derived and is not editable —
556
+ * but is translatable per locale via `i18n`.
557
+ */
558
+ interface UpdateCatalogEntryBaseData {
559
+ label?: string;
560
+ description?: string | null;
561
+ /** Feature-only (#13): static default icon (Quasar icon name). Quotas ignore it. */
562
+ icon?: string | null;
563
+ /** Feature-only (#13): tier hint (open union). Quotas ignore it. */
564
+ tier?: FeatureTier | null;
565
+ }
566
+ /**
567
+ * Result of `POST …/discovery/sync` — counters for the UI.
568
+ * `discovered`/`retired` are scan events (new in code / disappeared from
569
+ * code); `outdated` counts `approved` entries that the sync flipped to
570
+ * `outdated` due to signature drift (#20). `replaced` counts entries to
571
+ * which the sync assigned a successor pointer (`successorKey`) in this run
572
+ * (#39).
573
+ */
574
+ interface SyncDiscoveryResult {
575
+ capabilities: {
576
+ discovered: number;
577
+ retired: number;
578
+ total: number;
579
+ };
580
+ features: {
581
+ discovered: number;
582
+ retired: number;
583
+ outdated: number;
584
+ replaced: number;
585
+ total: number;
586
+ };
587
+ quotas: {
588
+ discovered: number;
589
+ retired: number;
590
+ outdated: number;
591
+ replaced: number;
592
+ total: number;
593
+ };
594
+ }
595
+ /**
596
+ * Polymorphic target type of a MarketingProjection. References the
597
+ * versioned entity (plan or bundle version) that is
598
+ * marketed publicly.
599
+ */
600
+ type MarketingTargetType = 'PLAN' | 'BUNDLE';
601
+ /**
602
+ * A top-feature entry in the public-catalog card.
603
+ *
604
+ * - `key` — optional reference to a feature/quota key. If `key` is
605
+ * set and `label` is empty, the displayed label is resolved (translated)
606
+ * from the `FeatureCatalogEntry`/`QuotaCatalogEntry` in the respective
607
+ * locale. This keeps the card language-reactive.
608
+ * - `label` — free text or override; empty + `key` set = auto label.
609
+ * - `strong` — optional bold-set addition (e.g. "up to 100", "5 GB").
610
+ */
611
+ interface MarketingTopFeature {
612
+ key?: string;
613
+ label: string;
614
+ strong: string;
615
+ }
616
+ /**
617
+ * Locale-specific marketing texts per plan/bundle version.
618
+ * Read and projected by the Public-Catalog-Controller
619
+ * (`GET /public/catalog?locale=de`).
620
+ *
621
+ * Polymorphic reference via (`targetType`, `targetVersionId`) — no FK,
622
+ * app logic checks existence on read.
623
+ */
624
+ interface MarketingProjectionRow {
625
+ id: string;
626
+ projectKey: string;
627
+ targetType: MarketingTargetType;
628
+ targetVersionId: string;
629
+ /** ISO-639-1, optionally with region suffix (`de`, `en`, `de-AT`). */
630
+ locale: string;
631
+ displayLabel: string;
632
+ description: string;
633
+ /**
634
+ * Visibility in the public catalog. `false` = the projection exists,
635
+ * but the plan is not shown on the pricing page (e.g. during
636
+ * preparation).
637
+ */
638
+ visible: boolean;
639
+ /**
640
+ * Optional badge at the top of the card (e.g. "Popular", "New"). Empty
641
+ * string = no badge.
642
+ */
643
+ badge: string;
644
+ /**
645
+ * Top features that appear prominently on the public-catalog card.
646
+ * Order is the display order.
647
+ */
648
+ topFeatures: MarketingTopFeature[];
649
+ /** Free trial active — controls the automatic CTA text. */
650
+ trialEnabled: boolean;
651
+ /** Length of the trial in days (only relevant when `trialEnabled`). */
652
+ trialDays: number;
653
+ /**
654
+ * Optional formatted price tag (e.g. "€ 9.90 / month" or "on
655
+ * request"). null = pricing is formatted automatically from
656
+ * PlanVersion.monthlyNet etc. at render time.
657
+ */
658
+ priceTag: string | null;
659
+ /**
660
+ * Overrides the automatically generated call-to-action text
661
+ * (e.g. "Get in touch"). null = auto text from trial/pricing.
662
+ */
663
+ ctaLabel: string | null;
664
+ /** Sorting in the public list (DESC). Higher values first. */
665
+ priority: number;
666
+ /** "Recommended" star or featured highlight in the UI. */
667
+ highlight: boolean;
668
+ createdAt: string;
669
+ updatedAt: string;
670
+ }
671
+ /** Filter for `MarketingProjectionRepository.list()`. At least projectKey. */
672
+ interface MarketingProjectionFilter {
673
+ projectKey: string;
674
+ targetType?: MarketingTargetType;
675
+ targetVersionId?: string;
676
+ locale?: string;
677
+ }
678
+ interface CreateMarketingProjectionData {
679
+ projectKey: string;
680
+ targetType: MarketingTargetType;
681
+ targetVersionId: string;
682
+ locale?: string;
683
+ displayLabel: string;
684
+ description: string;
685
+ visible?: boolean;
686
+ badge?: string;
687
+ topFeatures?: MarketingTopFeature[];
688
+ trialEnabled?: boolean;
689
+ trialDays?: number;
690
+ priceTag?: string | null;
691
+ ctaLabel?: string | null;
692
+ priority?: number;
693
+ highlight?: boolean;
694
+ }
695
+ interface UpdateMarketingProjectionData {
696
+ displayLabel?: string;
697
+ description?: string;
698
+ visible?: boolean;
699
+ badge?: string;
700
+ topFeatures?: MarketingTopFeature[];
701
+ trialEnabled?: boolean;
702
+ trialDays?: number;
703
+ priceTag?: string | null;
704
+ ctaLabel?: string | null;
705
+ priority?: number;
706
+ highlight?: boolean;
707
+ }
708
+
709
+ type PromoCodeValueType = 'PERCENT' | 'ABSOLUTE';
710
+ type PromoCodeDurationType = 'ONCE' | 'MONTHS' | 'BILLING_CYCLES';
711
+ type PromoCodeStatus = 'ACTIVE' | 'PAUSED' | 'EXHAUSTED' | 'EXPIRED';
712
+ type PromoCodeRedemptionStatus = 'ACTIVE' | 'REVERSED' | 'EXPIRED';
713
+ type BillingCycle = 'MONTHLY' | 'YEARLY';
714
+ type PromoCodeValidationResult = 'VALID' | 'EXPIRED' | 'EXHAUSTED' | 'NOT_FOUND' | 'PAUSED' | 'NOT_APPLICABLE' | 'FIRST_TIME_ONLY' | 'ZERO_INVOICE_BLOCKED' | 'MIN_AMOUNT_NOT_REACHED' | 'RATE_LIMITED';
715
+ interface CreatePromoCodeRequest {
716
+ /** A–Z, 0–9, '-' and '_'; 4–32 characters; case-insensitive, stored in UPPER. */
717
+ code: string;
718
+ valueType: PromoCodeValueType;
719
+ /** For PERCENT: 0.01–100. For ABSOLUTE: > 0 in catalog currency. */
720
+ value: number;
721
+ durationType: PromoCodeDurationType;
722
+ /** Required for MONTHS / BILLING_CYCLES, null for ONCE. */
723
+ durationValue?: number | null;
724
+ validFrom?: string | null;
725
+ validUntil?: string | null;
726
+ maxRedemptions?: number | null;
727
+ /** Empty = all plans. */
728
+ appliesToPlans?: PlanId[];
729
+ appliesToBilling?: BillingCycle | null;
730
+ firstTimeCustomersOnly?: boolean;
731
+ minimumPlanAmountGross?: number | null;
732
+ /** Default false: discount must not reduce the invoice to 0. */
733
+ allowZeroInvoice?: boolean;
734
+ description?: string | null;
735
+ campaignTag?: string | null;
736
+ /** SKR account for accounting; project-specific. */
737
+ revenueDeductionAccount?: string | null;
738
+ }
739
+ interface UpdatePromoCodeRequest {
740
+ status?: 'ACTIVE' | 'PAUSED';
741
+ description?: string | null;
742
+ validUntil?: string | null;
743
+ maxRedemptions?: number | null;
744
+ campaignTag?: string | null;
745
+ }
746
+ interface PromoCode {
747
+ id: string;
748
+ code: string;
749
+ valueType: PromoCodeValueType;
750
+ value: number;
751
+ durationType: PromoCodeDurationType;
752
+ durationValue: number | null;
753
+ validFrom: string | null;
754
+ validUntil: string | null;
755
+ maxRedemptions: number | null;
756
+ redemptionsCount: number;
757
+ appliesToPlans: PlanId[];
758
+ appliesToBilling: BillingCycle | null;
759
+ firstTimeCustomersOnly: boolean;
760
+ minimumPlanAmountGross: number | null;
761
+ allowZeroInvoice: boolean;
762
+ status: PromoCodeStatus;
763
+ description: string | null;
764
+ campaignTag: string | null;
765
+ revenueDeductionAccount: string | null;
766
+ createdAt: string;
767
+ deletedAt: string | null;
768
+ }
769
+ interface PromoCodeRedemption {
770
+ id: string;
771
+ promoCodeId: string;
772
+ subscriptionId: string;
773
+ tenantId: string;
774
+ appliedValueType: PromoCodeValueType;
775
+ appliedValue: number;
776
+ appliedDurationType: PromoCodeDurationType;
777
+ appliedDurationValue: number | null;
778
+ startsAt: string;
779
+ endsAt: string | null;
780
+ status: PromoCodeRedemptionStatus;
781
+ redeemedAt: string;
782
+ reversedAt: string | null;
783
+ }
784
+ interface PromoCodeValidationLog {
785
+ id: string;
786
+ /** null for NOT_FOUND. */
787
+ promoCodeId: string | null;
788
+ codeAttempt: string;
789
+ /** Hash, not plaintext. */
790
+ ipHash: string | null;
791
+ sessionId: string | null;
792
+ result: PromoCodeValidationResult;
793
+ createdAt: string;
794
+ }
795
+
796
+ type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'PAST_DUE' | 'CANCELED' | 'PENDING_SALES';
797
+ interface Subscription {
798
+ id: string;
799
+ tenantId: string;
800
+ planId: PlanId;
801
+ /** FK to PlanVersion — binding for existing subscriptions (contract protection P1). */
802
+ planVersionId: string;
803
+ billingCycle: BillingCycle;
804
+ status: SubscriptionStatus;
805
+ /** Override per Tenant; unset fields fall back to the PlanVersion. */
806
+ customLimits?: Partial<Record<QuotaKey, number>>;
807
+ /** ENTERPRISE special contract. */
808
+ customMonthlyNet?: number | null;
809
+ isPilot: boolean;
810
+ pilotEndsAt: string | null;
811
+ pilotNote?: string | null;
812
+ trialEndsAt: string | null;
813
+ startedAt: string;
814
+ canceledAt: string | null;
815
+ currentPeriodStart: string | null;
816
+ currentPeriodEnd: string | null;
817
+ /** Plan version migration (see ROADMAP §6). */
818
+ pendingPlanVersionId: string | null;
819
+ pendingPlanVersionEffectiveAt: string | null;
820
+ pendingPlanVersionAccepted: boolean;
821
+ pendingPlanVersionAcceptedAt: string | null;
822
+ pendingPlanVersionAcceptedByUserId: string | null;
823
+ pendingPlanVersionNotifiedAt: string | null;
824
+ pendingPlanVersionReminderSentAt: string | null;
825
+ /** Plan change at period end (orthogonal to pendingPlanVersionId). */
826
+ pendingPlanId: PlanId | null;
827
+ pendingBillingCycle: BillingCycle | null;
828
+ pendingEffectiveAt: string | null;
829
+ postTrialPlanId: PlanId | null;
830
+ trialEntitlementPlanId: PlanId | null;
831
+ createdAt: string;
832
+ updatedAt: string;
833
+ }
834
+ type VersionChangeDirection = 'IMPROVEMENT' | 'REGRESSION' | 'NEUTRAL';
835
+ interface VersionChange {
836
+ field: string;
837
+ oldValue: unknown;
838
+ newValue: unknown;
839
+ direction: VersionChangeDirection;
840
+ }
841
+ interface VersionedEntityBase {
842
+ id: string;
843
+ version: number;
844
+ /** Predecessor the draft diffed against. */
845
+ baseVersionId: string | null;
846
+ /** null = draft, set = live or superseded. */
847
+ publishedAt: string | null;
848
+ /** set = no longer marketed, but contractually valid for existing subscriptions. */
849
+ supersededAt: string | null;
850
+ publishedChanges: VersionChange[] | null;
851
+ /** Required on publish; quoted in notification emails. */
852
+ changeNote: string;
853
+ /** Computed on publish. */
854
+ nonRegressive: boolean;
855
+ /**
856
+ * From when this version is active for *new* bookings.
857
+ * null = draft (no date yet). Required on publish; must lie strictly after
858
+ * the `validFrom` of the predecessor version.
859
+ */
860
+ validFrom: string | null;
861
+ /**
862
+ * Until when this version is available for *new* bookings; null = unlimited.
863
+ * Automatically set to `successor.validFrom - 1 day` when a successor
864
+ * version is published (auto-succession).
865
+ * Existing subscriptions (P1) are unaffected by this.
866
+ */
867
+ validUntil: string | null;
868
+ createdByUserId: string | null;
869
+ publishedByUserId: string | null;
870
+ createdAt: string;
871
+ updatedAt: string;
872
+ /**
873
+ * Computed on the list read in the service: `true` when no version with a
874
+ * higher `version` number (same lineage) exists. Needed to keep a
875
+ * published-but-future version editable — only the last one in the chain
876
+ * may be readjusted, because successor versions would otherwise become
877
+ * inconsistent. Optional, because adapter reads that do not set the field
878
+ * are interpreted by the helper as `undefined → false` (= frozen).
879
+ */
880
+ isLatestInChain?: boolean;
881
+ /**
882
+ * Computed on the list read in the service: number of subscriptions that
883
+ * bind this version. Needed to keep a published-but-future version
884
+ * editable — as soon as a booking exists, the version is frozen because it
885
+ * has become part of the contract. Optional for backwards-compat reasons;
886
+ * the helper defensively interprets `undefined` as `>0` (= frozen). How the
887
+ * adapter counts depends on the version type: PlanVersion via
888
+ * `Subscription.planVersionId` (+ `pendingPlanVersionId`), BundleVersion
889
+ * via the respective app-specific Subscription→Bundle binding.
890
+ */
891
+ subscriptionCount?: number;
892
+ }
893
+ /**
894
+ * SubscriptionBundle — wire format of the `subscription_bundles` junction
895
+ *. Models a **standalone** bundle booking of a
896
+ * subscription, analogous to the plan booking; bundles are maintained with
897
+ * their own minimum term + their own cancellation (user requirement from
898
+ * P11.7.3).
899
+ *
900
+ * - `bundleVersionId` binds the booking to an *exact* BundleVersion
901
+ * (immutable; bundle updates only take effect after a new version with its
902
+ * own migration).
903
+ * - `startedAt` is the contract start of this booking.
904
+ * - `minimumTermEndsAt` = end of the minimum term; `null` = no minimum term
905
+ * (platform default = 12 months, set service-side).
906
+ * - `canceledAt` / `canceledEffectiveAt`: cancellation anchor vs. effective
907
+ * date. Before the minimum term ends, `canceledEffectiveAt =
908
+ * minimumTermEndsAt`, otherwise the subscription's period end.
909
+ *
910
+ * Existing-subscription protection: for SuperAdmin editor editability,
911
+ * `SubscriptionRepository.countByBundleVersionId` counts the non-canceled
912
+ * entries (`canceledAt IS NULL OR canceledEffectiveAt > NOW()`).
913
+ */
914
+ interface SubscriptionBundleRecord {
915
+ id: string;
916
+ subscriptionId: string;
917
+ bundleVersionId: string;
918
+ startedAt: Date;
919
+ minimumTermEndsAt: Date | null;
920
+ canceledAt: Date | null;
921
+ canceledEffectiveAt: Date | null;
922
+ createdAt: Date;
923
+ updatedAt: Date;
924
+ }
925
+ /**
926
+ * API view for `GET /billing/subscription-bundles`: record + denormalized
927
+ * bundle info (key/label/price) from the booked BundleVersion. This lets the
928
+ * UI show booked bundles without a catalog join — the catalog may exclude
929
+ * filtered/superseded versions, otherwise the display falls back to the raw
930
+ * bundleVersionId (UUID).
931
+ */
932
+ interface SubscriptionBundleView extends SubscriptionBundleRecord {
933
+ bundleKey: string | null;
934
+ label: string | null;
935
+ monthlyNet: string | null;
936
+ }
937
+ interface CreateSubscriptionBundleData {
938
+ subscriptionId: string;
939
+ bundleVersionId: string;
940
+ startedAt: Date;
941
+ /** Default = startedAt + 12 months, unless set. */
942
+ minimumTermEndsAt?: Date | null;
943
+ }
944
+ interface CancelSubscriptionBundleData {
945
+ canceledAt: Date;
946
+ /**
947
+ * Effective date the cancellation takes effect. The service computes it:
948
+ * max(canceledAt + 1 period, minimumTermEndsAt).
949
+ */
950
+ canceledEffectiveAt: Date;
951
+ }
952
+ interface PlanVersion extends VersionedEntityBase {
953
+ planId: PlanId;
954
+ features: FeatureKey[];
955
+ quotas: Partial<Record<QuotaKey, number>>;
956
+ monthlyNet: number;
957
+ yearlyNet: number;
958
+ marketed: boolean;
959
+ }
960
+
961
+ /**
962
+ * Usage whitelist for a bundle. An empty/missing list means the bundle may
963
+ * be used with any plan.
964
+ */
965
+ interface BundleCompatibility {
966
+ /**
967
+ * Whitelist of plan IDs; only these may use the bundle.
968
+ * Empty/missing = all plans allowed.
969
+ */
970
+ planIds?: string[];
971
+ }
972
+ /**
973
+ * Pricing override for a plan context.
974
+ *
975
+ * - `monthlyNet` / `yearlyNet` as string (Decimal wire format)
976
+ * - `null` = explicit "free in this context"
977
+ * - undefined / field missing = no override for this cycle
978
+ */
979
+ interface BundlePricingOverride {
980
+ /** If set: override applies only with this plan. */
981
+ planId?: string;
982
+ monthlyNet?: string | null;
983
+ yearlyNet?: string | null;
984
+ }
985
+ /**
986
+ * Bundle — reusable component of Features + Quotas + Pricing.
987
+ * Master entity without content; the purchasable fields live on BundleVersionRow.
988
+ */
989
+ interface BundleRow {
990
+ id: string;
991
+ projectKey: string;
992
+ bundleKey: string;
993
+ label: string;
994
+ description: string | null;
995
+ icon: string | null;
996
+ sortOrder: number;
997
+ /** Locale translations of `label`/`description`. */
998
+ i18n: CatalogEntryI18n;
999
+ createdAt: string;
1000
+ updatedAt: string;
1001
+ deletedAt: string | null;
1002
+ }
1003
+ /**
1004
+ * BundleVersion — versioned composition (Features, Quotas, Pricing).
1005
+ * `quotas` is `Record<QuotaKey, number>`; `-1` = unlimited; a missing key
1006
+ * contributes 0.
1007
+ */
1008
+ interface BundleVersionRow extends VersionedEntityBase {
1009
+ bundleId: string;
1010
+ /** Denormalized for UI (avoids an extra lookup). */
1011
+ bundleKey: string;
1012
+ /** Denormalized for UI. */
1013
+ label: string;
1014
+ features: FeatureKey[];
1015
+ quotas: Record<QuotaKey, number>;
1016
+ compatibility: BundleCompatibility;
1017
+ pricingOverrides: BundlePricingOverride[];
1018
+ /** Default price; null = only via override pricing. */
1019
+ monthlyNet: string | null;
1020
+ yearlyNet: string | null;
1021
+ marketed: boolean;
1022
+ }
1023
+ /**
1024
+ * Fields that must be set when creating a new bundle master.
1025
+ * `id`, `createdAt`, `updatedAt`, `deletedAt` are assigned by the repository.
1026
+ * Version-specific fields (Features, Quotas, Pricing) belong in the
1027
+ * first BundleVersion via `CreateBundleVersionDraftData`.
1028
+ */
1029
+ interface CreateBundleData {
1030
+ projectKey: string;
1031
+ bundleKey: string;
1032
+ label: string;
1033
+ description?: string | null;
1034
+ icon?: string | null;
1035
+ sortOrder?: number;
1036
+ i18n?: CatalogEntryI18n;
1037
+ }
1038
+ /**
1039
+ * Fields that may be changed on the bundle master. `bundleKey` and
1040
+ * `projectKey` are intentionally not here — master identity is immutable;
1041
+ * whoever wants to change them creates a new bundle and retires the old one.
1042
+ */
1043
+ interface UpdateBundleData {
1044
+ label?: string;
1045
+ description?: string | null;
1046
+ icon?: string | null;
1047
+ sortOrder?: number;
1048
+ i18n?: CatalogEntryI18n;
1049
+ }
1050
+ /**
1051
+ * Fields of a new BundleVersion in draft status (`publishedAt = null`).
1052
+ * Created by the SuperAdmin, later published via `publishBundleVersion()`.
1053
+ * Only **one** draft version per bundle allowed
1054
+ * (see partial unique index in the migration).
1055
+ */
1056
+ interface CreateBundleVersionDraftData {
1057
+ bundleId: string;
1058
+ /** Predecessor version the diff is computed against (null for v1). */
1059
+ baseVersionId?: string | null;
1060
+ features: FeatureKey[];
1061
+ quotas?: Record<QuotaKey, number>;
1062
+ compatibility?: BundleCompatibility;
1063
+ pricingOverrides?: BundlePricingOverride[];
1064
+ monthlyNet?: string | null;
1065
+ yearlyNet?: string | null;
1066
+ marketed?: boolean;
1067
+ /** Required at publish (see contract protection P3 in). */
1068
+ changeNote?: string;
1069
+ /**
1070
+ * From when this version should be active for *new* bookings. Required
1071
+ * at the latest at publish (see `PublishBundleVersionData`); can
1072
+ * already be pre-noted in the draft. Format: ISO-8601 (`YYYY-MM-DD`).
1073
+ */
1074
+ validFrom?: string | null;
1075
+ /**
1076
+ * Optional; null = unlimited until superseded by a successor
1077
+ * version (auto succession). When a successor version is published it is
1078
+ * automatically set by the service to `successor.validFrom - 1 day`.
1079
+ */
1080
+ validUntil?: string | null;
1081
+ createdByUserId?: string | null;
1082
+ }
1083
+ /**
1084
+ * Fields of a draft BundleVersion that may still be changed.
1085
+ * With also for published-but-future versions
1086
+ * (latest-in-chain, 0 subs, validFrom > now) — see
1087
+ * `isVersionEditable`.
1088
+ */
1089
+ interface UpdateBundleVersionDraftData {
1090
+ features?: FeatureKey[];
1091
+ quotas?: Record<QuotaKey, number>;
1092
+ compatibility?: BundleCompatibility;
1093
+ pricingOverrides?: BundlePricingOverride[];
1094
+ monthlyNet?: string | null;
1095
+ yearlyNet?: string | null;
1096
+ marketed?: boolean;
1097
+ changeNote?: string;
1098
+ /**
1099
+ * New `validFrom` for the version. Freely settable when updating a
1100
+ * draft; for a published-but-future version the new date must still
1101
+ * lie in the future — the service checks that with
1102
+ * `isVersionEditable` against the freshly loaded state.
1103
+ */
1104
+ validFrom?: string | null;
1105
+ validUntil?: string | null;
1106
+ }
1107
+ /**
1108
+ * Input for `publishBundleVersion()`. `nonRegressive` and
1109
+ * `publishedChanges` are computed by the service from the diff against the
1110
+ * predecessor version; the caller supplies only confirmation
1111
+ * + user tag + validity dates.
1112
+ *
1113
+ * `validFrom` is **required** at publish (analogous to `PublishPlanVersionData`).
1114
+ * If the draft already carries a `validFrom`, it is optional here. The
1115
+ * service validates strictly > `validFrom` of the predecessor version and sets
1116
+ * its `validUntil` via auto succession to `validFrom - 1 day`.
1117
+ */
1118
+ interface PublishBundleVersionData {
1119
+ publishedByUserId: string | null;
1120
+ /**
1121
+ * If true and the diff classifies the version as regressive,
1122
+ * it is published anyway — relevant for bulk-publish MFA confirmation
1123
+ * ( editor-UI obligations).
1124
+ */
1125
+ forceRegressive?: boolean;
1126
+ /**
1127
+ * Allows publish despite an explicit price of 0.00. Default false: an
1128
+ * explicit 0.00 publish is blocked (protection against seed placeholders).
1129
+ * `null` prices (override resolution) are not affected.
1130
+ */
1131
+ allowZeroPrice?: boolean;
1132
+ /**
1133
+ * Required at publish if the draft has no `validFrom`. Must lie
1134
+ * strictly after `validFrom` of the predecessor version. ISO-8601
1135
+ * (`YYYY-MM-DD` or full timestamp).
1136
+ */
1137
+ validFrom?: string | null;
1138
+ /**
1139
+ * Optional; null = valid indefinitely (fits the last version
1140
+ * of a bundle). When a successor version is created it is automatically
1141
+ * overwritten by the service.
1142
+ */
1143
+ validUntil?: string | null;
1144
+ }
1145
+ /**
1146
+ * Code of a strict-mode violation. lists the eight rules
1147
+ * that are checked; each rule has its own code so the UI can show
1148
+ * focused help texts.
1149
+ */
1150
+ type StrictModeWarningCode = 'CAPABILITY_MISSING' | 'CAPABILITY_RETIRED' | 'FEATURE_MISSING' | 'FEATURE_PLANNED_ONLY' | 'BUNDLE_FEATURE_UNKNOWN' | 'BUNDLE_PLAN_KEY_UNKNOWN' | 'PLAN_FEATURE_UNKNOWN' | 'PLAN_FEATURE_NOT_APPROVED' | 'BUNDLE_FEATURE_NOT_APPROVED' | 'PLAN_FEATURE_DEPENDENCY_UNSATISFIED' | 'BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED' | 'QUOTA_MISSING' | 'QUOTA_NOT_APPROVED' | 'VERSION_PUBLISH_OVERLAP';
1151
+ /**
1152
+ * A strict-mode violation. `field` points to the violating field
1153
+ * (e.g. `'features[3]'`), `value` is the concrete value (e.g. `'INVENTORY'`).
1154
+ */
1155
+ interface StrictModeWarning {
1156
+ code: StrictModeWarningCode;
1157
+ /** Human-readable reason (German). */
1158
+ message: string;
1159
+ /** Path to the violating field; optional. */
1160
+ field?: string;
1161
+ /** The concrete violating value; optional. */
1162
+ value?: string;
1163
+ }
1164
+ /**
1165
+ * Service result for mutating Bundle operations
1166
+ * (createDraft, updateDraft, publish): returns the persisted row plus
1167
+ * a list of strict-mode warnings. In `warn-only` mode the
1168
+ * warnings go into the UI as a banner; in `blocking` mode the service throws
1169
+ * HTTP 422 instead, with the same warning list as the body.
1170
+ */
1171
+ interface BundleVersionMutationResult {
1172
+ bundleVersion: BundleVersionRow;
1173
+ warnings: StrictModeWarning[];
1174
+ }
1175
+
1176
+ /** Promotion type. */
1177
+ type PromotionType = 'percent' | 'amount' | 'intro' | 'freeMonths';
1178
+ /** Billing cycle for which the promotion applies. */
1179
+ type PromotionBillingCycle = 'monthly' | 'yearly' | 'both';
1180
+ /** Derived time status (from validFrom/validTo + today). */
1181
+ type PromotionStatus = 'scheduled' | 'active' | 'expired';
1182
+ /** Target type of a promotion. Missing/undefined means legacy `PLAN`. */
1183
+ type PromotionTargetType = 'PLAN' | 'BUNDLE' | 'OFFER';
1184
+ /**
1185
+ * Type-dependent promotion value:
1186
+ * - `percent`/`amount` → number
1187
+ * - `intro` → `{ price, months }`
1188
+ * - `freeMonths` → number (count of free months)
1189
+ */
1190
+ type PromotionValue = number | {
1191
+ price: number;
1192
+ months: number;
1193
+ };
1194
+ /** Locale-specific promotion texts. */
1195
+ interface PromotionI18nFields {
1196
+ badge?: string;
1197
+ fineprint?: string;
1198
+ }
1199
+ /** `{ 'de': { badge, fineprint }, 'en': { … } }`. */
1200
+ type PromotionI18n = Record<string, PromotionI18nFields>;
1201
+ /** Wire format of a `promotions` row. */
1202
+ interface PromotionRow {
1203
+ id: string;
1204
+ projectKey: string;
1205
+ /** Internal label (not public). */
1206
+ internalLabel: string;
1207
+ type: PromotionType;
1208
+ value: PromotionValue;
1209
+ /** Plan keys the promotion applies to. */
1210
+ appliesTo: string[];
1211
+ /** Target type of the keys in `appliesTo`. Missing = PLAN. */
1212
+ targetType?: PromotionTargetType;
1213
+ billingCycle: PromotionBillingCycle;
1214
+ /** ISO date. */
1215
+ validFrom: string;
1216
+ validTo: string;
1217
+ /** On overlap, the highest value wins. */
1218
+ priority: number;
1219
+ /** Language restriction; null = all locales. */
1220
+ onlyLocales: string[] | null;
1221
+ requiresCoupon: boolean;
1222
+ /** Referenced `PromoCode` codes (only relevant when requiresCoupon). */
1223
+ codes: string[];
1224
+ /** UI accent color (timeline/ribbon). */
1225
+ color: string;
1226
+ i18n: PromotionI18n;
1227
+ createdAt: string;
1228
+ updatedAt: string;
1229
+ }
1230
+ interface PromotionFilter {
1231
+ projectKey: string;
1232
+ }
1233
+ interface CreatePromotionData {
1234
+ projectKey: string;
1235
+ internalLabel: string;
1236
+ type: PromotionType;
1237
+ value: PromotionValue;
1238
+ appliesTo?: string[];
1239
+ targetType?: PromotionTargetType;
1240
+ billingCycle?: PromotionBillingCycle;
1241
+ validFrom: string;
1242
+ validTo: string;
1243
+ priority?: number;
1244
+ onlyLocales?: string[] | null;
1245
+ requiresCoupon?: boolean;
1246
+ codes?: string[];
1247
+ color?: string;
1248
+ i18n?: PromotionI18n;
1249
+ }
1250
+ interface UpdatePromotionData {
1251
+ internalLabel?: string;
1252
+ type?: PromotionType;
1253
+ value?: PromotionValue;
1254
+ appliesTo?: string[];
1255
+ targetType?: PromotionTargetType;
1256
+ billingCycle?: PromotionBillingCycle;
1257
+ validFrom?: string;
1258
+ validTo?: string;
1259
+ priority?: number;
1260
+ onlyLocales?: string[] | null;
1261
+ requiresCoupon?: boolean;
1262
+ codes?: string[];
1263
+ color?: string;
1264
+ i18n?: PromotionI18n;
1265
+ }
1266
+ /** Time status of a promotion relative to `today` (default: now). */
1267
+ declare function promoStatus(promo: Pick<PromotionRow, 'validFrom' | 'validTo'>, today?: Date): PromotionStatus;
1268
+ /**
1269
+ * Selects **exactly one** applicable promotion for plan + locale + cycle:
1270
+ * filtered on `appliesTo`, `billingCycle`, `onlyLocales`, status `active`,
1271
+ * `!requiresCoupon`; if several, the highest `priority` wins.
1272
+ */
1273
+ declare function pickActivePromo(promotions: PromotionRow[], targetKey: string, locale: string, cycle: 'monthly' | 'yearly', today?: Date, targetType?: PromotionTargetType): PromotionRow | null;
1274
+ /** Result of `applyPromo` — type-dependent price projection. */
1275
+ type PromotionResult = {
1276
+ kind: 'percent';
1277
+ discounted: number;
1278
+ original: number;
1279
+ pct: number;
1280
+ } | {
1281
+ kind: 'amount';
1282
+ discounted: number;
1283
+ original: number;
1284
+ saved: number;
1285
+ } | {
1286
+ kind: 'intro';
1287
+ discounted: number;
1288
+ original: number;
1289
+ months: number;
1290
+ } | {
1291
+ kind: 'free';
1292
+ discounted: number;
1293
+ original: number;
1294
+ months: number;
1295
+ };
1296
+ /** Applies the promotion math to a base price. */
1297
+ declare function applyPromo(promo: PromotionRow | null, basePrice: number | null): PromotionResult | null;
1298
+
1299
+ type CheckoutOfferLineItemKind = 'plan' | 'bundle' | 'discount';
1300
+ /** Frozen billable line item in the offer. */
1301
+ interface CheckoutOfferLineItem {
1302
+ kind: CheckoutOfferLineItemKind;
1303
+ sourceKey: string;
1304
+ sourceVersionId?: string | null;
1305
+ titleSnapshot: string;
1306
+ descriptionSnapshot?: string | null;
1307
+ quantity: number;
1308
+ unit?: string | null;
1309
+ priceNet: number;
1310
+ priceGross: number;
1311
+ billingCycle: 'monthly' | 'yearly';
1312
+ minimumTermUntil?: string | Date | null;
1313
+ featuresSnapshot?: string[];
1314
+ quotaEffectsSnapshot?: Record<string, number>;
1315
+ metadata?: Record<string, unknown> | null;
1316
+ }
1317
+ /** Frozen applied catalog promotion. */
1318
+ interface CheckoutOfferPromotionSnapshot {
1319
+ id: string | null;
1320
+ type: string;
1321
+ value: unknown;
1322
+ label: string;
1323
+ resolvedAmountNet: number;
1324
+ appliesTo: string[];
1325
+ billingCycle: 'monthly' | 'yearly' | 'both';
1326
+ }
1327
+ /** Frozen promo-code preview before contract conclusion. */
1328
+ interface CheckoutOfferPromoCodeSnapshot {
1329
+ code: string;
1330
+ label: string;
1331
+ valueType: string;
1332
+ value: number;
1333
+ resolvedAmountNet: number;
1334
+ durationType?: string | null;
1335
+ durationValue?: number | null;
1336
+ }
1337
+ /** Structured price breakdown — frozen at offer time. */
1338
+ interface CheckoutOfferPriceBreakdown {
1339
+ currency: string;
1340
+ billingCycle: 'monthly' | 'yearly';
1341
+ /** Net base price of the plan. */
1342
+ planNet: number;
1343
+ /** Net surcharge from bundles. */
1344
+ bundlesNet: number;
1345
+ /** Net total before promo. */
1346
+ regularNet: number;
1347
+ /** Net total after promo. */
1348
+ effectiveNet: number;
1349
+ vatRate: number;
1350
+ /** Gross total after promo. */
1351
+ effectiveGross: number;
1352
+ }
1353
+ type CheckoutOfferStatus = 'open' | 'consumed' | 'expired';
1354
+ /** Wire format of a `checkout_offers` row. */
1355
+ interface CheckoutOfferRow {
1356
+ id: string;
1357
+ projectKey: string;
1358
+ /** Plan selected on the website. */
1359
+ planKey: string;
1360
+ /** Resolved plan version, if known. */
1361
+ planVersionId: string | null;
1362
+ billingCycle: 'monthly' | 'yearly';
1363
+ /** Applied promotion (active at offer time). */
1364
+ promotionId: string | null;
1365
+ /** Redeemed promo code, if the promotion was `requiresCoupon`. */
1366
+ promoCode: string | null;
1367
+ /** Added bundle keys. Legacy display; V3 uses `bundleVersionIds` + `lineItems`. */
1368
+ bundles: string[];
1369
+ /** Concrete BundleVersion IDs that the offer binds. */
1370
+ bundleVersionIds?: string[];
1371
+ priceBreakdown: CheckoutOfferPriceBreakdown;
1372
+ /** V3 contract line items, already resolved at offer time. */
1373
+ lineItems?: CheckoutOfferLineItem[];
1374
+ /** Active automatic promotions as snapshot. */
1375
+ promotionSnapshots?: CheckoutOfferPromotionSnapshot[];
1376
+ /** Redeemed promo code as snapshot. */
1377
+ promoCodeSnapshot?: CheckoutOfferPromoCodeSnapshot | null;
1378
+ locale: string;
1379
+ /** Temporal validity of the offer; null = repository/consumer policy. */
1380
+ validUntil?: string | null;
1381
+ status: CheckoutOfferStatus;
1382
+ /** Set as soon as a subscription has arisen from the offer. */
1383
+ consumedAt: string | null;
1384
+ createdAt: string;
1385
+ updatedAt: string;
1386
+ }
1387
+ interface CheckoutOfferFilter {
1388
+ projectKey: string;
1389
+ status?: CheckoutOfferStatus;
1390
+ }
1391
+ /** Body of `POST /public/checkout-offer` — called from the website. */
1392
+ interface CreateCheckoutOfferData {
1393
+ projectKey: string;
1394
+ planKey: string;
1395
+ planVersionId?: string | null;
1396
+ billingCycle: 'monthly' | 'yearly';
1397
+ promotionId?: string | null;
1398
+ promoCode?: string | null;
1399
+ bundles?: string[];
1400
+ bundleVersionIds?: string[];
1401
+ priceBreakdown: CheckoutOfferPriceBreakdown;
1402
+ lineItems?: CheckoutOfferLineItem[];
1403
+ promotionSnapshots?: CheckoutOfferPromotionSnapshot[];
1404
+ promoCodeSnapshot?: CheckoutOfferPromoCodeSnapshot | null;
1405
+ locale?: string;
1406
+ validUntil?: string | null;
1407
+ }
1408
+ /**
1409
+ * Body of `PATCH /public/checkout-offer/:id` — customization during
1410
+ * onboarding. `status`/`consumedAt` are not editable — `consume()`
1411
+ * sets them server-side.
1412
+ */
1413
+ interface UpdateCheckoutOfferData {
1414
+ billingCycle?: 'monthly' | 'yearly';
1415
+ promotionId?: string | null;
1416
+ promoCode?: string | null;
1417
+ bundles?: string[];
1418
+ bundleVersionIds?: string[];
1419
+ priceBreakdown?: CheckoutOfferPriceBreakdown;
1420
+ lineItems?: CheckoutOfferLineItem[];
1421
+ promotionSnapshots?: CheckoutOfferPromotionSnapshot[];
1422
+ promoCodeSnapshot?: CheckoutOfferPromoCodeSnapshot | null;
1423
+ locale?: string;
1424
+ validUntil?: string | null;
1425
+ }
1426
+
1427
+ /** Wire format of the `marketing_settings` row. */
1428
+ interface MarketingSettingsRow {
1429
+ projectKey: string;
1430
+ /** Runtime-activated subset of the `availableLocales` pool. */
1431
+ activeLocales: string[];
1432
+ updatedAt: string;
1433
+ }
1434
+ /** Body of `PUT /admin/catalog/marketing-settings`. */
1435
+ interface UpdateMarketingSettingsData {
1436
+ activeLocales: string[];
1437
+ }
1438
+
1439
+ /** Active promotion of a plan card — discount already computed. */
1440
+ interface PublicMarketingPromo {
1441
+ type: PromotionType;
1442
+ /** Locale-resolved badge (e.g. "Spring offer"). */
1443
+ badge: string;
1444
+ /** Locale-resolved fineprint below the CTA. */
1445
+ fineprint: string;
1446
+ /** UI accent color (ribbon). */
1447
+ color: string;
1448
+ /** Discounted net monthly price; null if not applicable. */
1449
+ discountedMonthlyNet: number | null;
1450
+ /** Discounted net yearly price; null if not applicable. */
1451
+ discountedYearlyNet: number | null;
1452
+ }
1453
+ /** A fully marketed plan card. */
1454
+ interface PublicMarketingPlan {
1455
+ planKey: string;
1456
+ label: string;
1457
+ /** Live PlanVersion ID — for the CheckoutOffer on click. */
1458
+ planVersionId: string;
1459
+ monthlyNet: number | null;
1460
+ yearlyNet: number | null;
1461
+ /** Editorial badge (empty = no badge). */
1462
+ badge: string;
1463
+ /** Teaser / description text. */
1464
+ description: string;
1465
+ highlight: boolean;
1466
+ /**
1467
+ * Formatted pricing tag from the MarketingProjection (#47, e.g.
1468
+ * "€ 9.90 / month" or "on request"). null/missing = frontends
1469
+ * format automatically from monthlyNet/yearlyNet.
1470
+ */
1471
+ priceTag?: string | null;
1472
+ /** CTA override; null = automatic text. */
1473
+ ctaLabel: string | null;
1474
+ trialEnabled: boolean;
1475
+ trialDays: number;
1476
+ topFeatures: MarketingTopFeature[];
1477
+ /** Sort priority DESC. */
1478
+ priority: number;
1479
+ /** Currently active promotion or null. */
1480
+ promo: PublicMarketingPromo | null;
1481
+ /** Feature keys included in the plan — for the comparison matrix. */
1482
+ features: string[];
1483
+ /** Quota limits of the plan (`-1` = unlimited) — for the matrix. */
1484
+ quotas: Record<string, number>;
1485
+ }
1486
+ /**
1487
+ * A marketed bundle card for the public catalog (P11.7.3 +
1488
+ * P11.7.4). Bundles are offered as standalone add-ons to plans;
1489
+ * `compatiblePlanKeys` lists the plans in which the bundle may be
1490
+ * booked (empty = all plans allowed).
1491
+ */
1492
+ interface PublicMarketingBundle {
1493
+ bundleKey: string;
1494
+ label: string;
1495
+ /** Live BundleVersion ID — for the `add` request of the tenant self-service. */
1496
+ bundleVersionId: string;
1497
+ monthlyNet: number | null;
1498
+ yearlyNet: number | null;
1499
+ /** Description text (locale-resolved, falls back to bundle base). */
1500
+ description: string;
1501
+ /**
1502
+ * Formatted pricing tag from the MarketingProjection (#47) — analogous
1503
+ * to `PublicMarketingPlan.priceTag`. null/missing = automatic
1504
+ * formatting from monthlyNet/yearlyNet.
1505
+ */
1506
+ priceTag?: string | null;
1507
+ /** Feature keys included in the bundle. */
1508
+ features: string[];
1509
+ /** Quota top-ups of the bundle (`-1` = unlimited). */
1510
+ quotas: Record<string, number>;
1511
+ /** Currently active bundle promotion or null. */
1512
+ promo: PublicMarketingPromo | null;
1513
+ /**
1514
+ * Plan keys the bundle is compatible with. Empty array =
1515
+ * universal for all plans. The UI filters the display accordingly.
1516
+ */
1517
+ compatiblePlanKeys: string[];
1518
+ /**
1519
+ * Uncovered feature dependencies (#35): union of the `requires` of the
1520
+ * contained features minus those contained in the bundle itself.
1521
+ * The configurator greys out the bundle when these keys lie neither in
1522
+ * the selected plan nor in the current selection. Missing/empty =
1523
+ * self-contained or no requires data available.
1524
+ */
1525
+ requiresFeatures?: string[];
1526
+ /**
1527
+ * Locale-resolved display labels for `features` ∪ `requiresFeatures`
1528
+ * (#48). `comparison.features` only covers the plan feature union —
1529
+ * bundle-only features (e.g. RESOURCE_MANAGEMENT) would otherwise get no
1530
+ * label. Source: curated FeatureCatalogEntries incl. i18n. Only keys
1531
+ * with a curated entry are included; frontends fall back to the key
1532
+ * itself for missing keys. Missing/empty = no
1533
+ * CatalogEntryRepository registered.
1534
+ */
1535
+ featureLabels?: Record<string, string>;
1536
+ }
1537
+ /** A row of the comparison matrix (feature or quota). */
1538
+ interface PublicComparisonRow {
1539
+ key: string;
1540
+ /** Locale-resolved display label. */
1541
+ label: string;
1542
+ /** Quotas only: display unit. */
1543
+ unit?: string;
1544
+ }
1545
+ /** Response of `GET /public/marketing-catalog`. */
1546
+ interface PublicMarketingCatalogResponse {
1547
+ projectKey: string;
1548
+ locale: string;
1549
+ currency: string;
1550
+ /** VAT rate in percent — for the CheckoutOffer price breakdown. */
1551
+ vatRate: number;
1552
+ /** Visible, marketed plans — sorted by `priority` DESC. */
1553
+ plans: PublicMarketingPlan[];
1554
+ /**
1555
+ * Visible, marketed bundles (P11.7.3 + P11.7.4) — as standalone
1556
+ * add-ons to the plans. The tenant self-service UI filters client-side
1557
+ * via `compatiblePlanKeys` against its own plan; the backend
1558
+ * does not filter here, so the marketing comparison page shows all
1559
+ * bundles.
1560
+ */
1561
+ bundles: PublicMarketingBundle[];
1562
+ /**
1563
+ * Row definitions of the comparison matrix — union of all
1564
+ * feature/quota keys across the visible plans, with labels.
1565
+ */
1566
+ comparison: {
1567
+ features: PublicComparisonRow[];
1568
+ quotas: PublicComparisonRow[];
1569
+ };
1570
+ }
1571
+
1572
+ /**
1573
+ * Code status of a capability in the decorator (`@ImplementsCapability`):
1574
+ *
1575
+ * - `active` — normally usable (default)
1576
+ * - `experimental` — WIP capability; shown in the UI with a warning
1577
+ * - `deprecated` — to be replaced (`replacementKey` recommended)
1578
+ * - `internal` — does not appear in the SuperAdmin UI, but in the snapshot hash
1579
+ */
1580
+ type DiscoveryCodeStatus = 'active' | 'experimental' | 'deprecated' | 'internal';
1581
+ /**
1582
+ * A single capability entry in the DiscoverySnapshot — matches the
1583
+ * wire format that `/admin/discovery` delivers.
1584
+ */
1585
+ interface DiscoveredCapability {
1586
+ capabilityKey: string;
1587
+ label: string | null;
1588
+ feature: string | null;
1589
+ status: DiscoveryCodeStatus;
1590
+ kind: CapabilityKind;
1591
+ owner: string | null;
1592
+ replacementKey: string | null;
1593
+ removalPlannedAt: string | null;
1594
+ reason: string | null;
1595
+ /**
1596
+ * Feature keys that this capability's feature requires at runtime
1597
+ * (#35). `null` = no dependencies — the default, so that snapshots
1598
+ * from older platform versions stay readable unchanged.
1599
+ */
1600
+ requires: string[] | null;
1601
+ /**
1602
+ * Old feature keys that this capability's feature replaces (#39,
1603
+ * hard path: the old code has already been deleted). `null` = none.
1604
+ */
1605
+ replaces: string[] | null;
1606
+ /**
1607
+ * Where the capability is declared — `ClassName.methodName` for
1608
+ * methods, `ClassName` for class level. Helps with forensics /
1609
+ * discovery diff.
1610
+ */
1611
+ declaredAt: string;
1612
+ }
1613
+ /** Aggregate of several capabilities with `feature: 'X'`. */
1614
+ interface DiscoveredFeature {
1615
+ featureKey: string;
1616
+ /** Capability keys that declare this feature via `feature: 'X'`. */
1617
+ capabilityKeys: string[];
1618
+ /**
1619
+ * Union of the capability `requires` minus its own featureKey (#35).
1620
+ * `null` = no dependencies (backward-compatible with old snapshots).
1621
+ */
1622
+ requires: string[] | null;
1623
+ /** Union of the capability `replaces` (#39). `null` = none. */
1624
+ replaces: string[] | null;
1625
+ }
1626
+ /**
1627
+ * Quota policy:
1628
+ * - `monthlyReset` — counter is reset to 0 at the start of the month
1629
+ * - `continuous` — counter grows monotonically (e.g. storage consumption)
1630
+ * - `hardCap` — on overrun, HTTP 429 / domain-level block
1631
+ */
1632
+ type DiscoveredQuotaPolicy = 'monthlyReset' | 'continuous' | 'hardCap';
1633
+ /** A quota declared in code via `@DefinesQuota`. */
1634
+ interface DiscoveredQuota {
1635
+ quotaKey: string;
1636
+ label: string;
1637
+ unit: string;
1638
+ policy: DiscoveredQuotaPolicy;
1639
+ feature: string | null;
1640
+ /** Old quotaKeys that this quota replaces (#39). `null` = none. */
1641
+ replaces: string[] | null;
1642
+ /** Where the quota is declared — `ClassName`. */
1643
+ declaredAt: string;
1644
+ /** Capability keys that reference this quota via `@EnforceQuota(quotaKey)`. */
1645
+ enforcedBy: string[];
1646
+ }
1647
+ /**
1648
+ * Complete discovery snapshot — built at boot time, delivered as JSON by
1649
+ * the AdminController, checked against the DB catalog by the strict-mode
1650
+ * check.
1651
+ */
1652
+ interface DiscoverySnapshot {
1653
+ schemaVersion: 1;
1654
+ /** ISO timestamp of the boot-time scan. */
1655
+ scannedAt: string;
1656
+ app: {
1657
+ /** projectKey, same concept as in the catalog tables. */
1658
+ key: string;
1659
+ /** Backend version, e.g. from package.json. */
1660
+ version: string;
1661
+ };
1662
+ capabilities: DiscoveredCapability[];
1663
+ features: DiscoveredFeature[];
1664
+ quotas: DiscoveredQuota[];
1665
+ /**
1666
+ * Canonical SHA256 hash over sorted/normalized snapshot data.
1667
+ * Stable across boot restarts, serves as the ETag for `/admin/discovery`.
1668
+ */
1669
+ hash: string;
1670
+ }
1671
+
1672
+ interface EffectiveLimitsSnapshot {
1673
+ plan: string;
1674
+ quotas: Record<string, number>;
1675
+ features: string[];
1676
+ }
1677
+
1678
+ interface FeatureUiMeta {
1679
+ /** Visible label for plan comparison tables, add-on lists. */
1680
+ label: string;
1681
+ /** Long description for tooltips, add-on cards. */
1682
+ description: string;
1683
+ /** Quasar icon name (e.g. 'directions_car', 'groups'). */
1684
+ icon: string;
1685
+ /** Mirror of `PlanCatalog.features[].plannedOnly` — cache for the UI without a catalog roundtrip. */
1686
+ plannedOnly?: boolean;
1687
+ /** true = base infrastructure, included in every plan (not bookable). */
1688
+ core?: boolean;
1689
+ }
1690
+ /** Map FeatureKey → UI metadata. Consumer apps supply a complete table. */
1691
+ type FeatureUiRegistry = Record<string, FeatureUiMeta>;
1692
+
1693
+ /** Alias for historical compatibility — equivalent to VersionChangeDirection. */
1694
+ type ChangeDirection = VersionChangeDirection;
1695
+ interface DiffResult {
1696
+ nonRegressive: boolean;
1697
+ changes: VersionChange[];
1698
+ }
1699
+ /**
1700
+ * `Decimal | string | number` — the three forms in which prices appear
1701
+ * in the platform. `Decimal` is the Prisma class (with
1702
+ * `.toNumber()`), which is not imported directly in order to keep the
1703
+ * platform Prisma-free — a structural view suffices.
1704
+ */
1705
+ type DecimalLike = number | string | {
1706
+ toNumber(): number;
1707
+ };
1708
+ interface PlanVersionFields {
1709
+ features: FeatureKey[];
1710
+ maxUsers: number;
1711
+ maxVehicles: number;
1712
+ maxStorageGb: number;
1713
+ monthlyNet: DecimalLike;
1714
+ yearlyNet: DecimalLike;
1715
+ }
1716
+ interface BundleVersionFields {
1717
+ features: FeatureKey[];
1718
+ /** Quota contributions of the bundle. -1 = unlimited; missing key = 0. */
1719
+ quotas: Record<QuotaKey, number>;
1720
+ /** Default pricing; null = only override pricing possible. */
1721
+ monthlyNet: DecimalLike | null;
1722
+ yearlyNet: DecimalLike | null;
1723
+ }
1724
+ declare function classifyPlanDiff(oldV: PlanVersionFields, newV: PlanVersionFields): DiffResult;
1725
+ /**
1726
+ * Classification of a BundleVersion diff for contract protection.
1727
+ *
1728
+ * Quota comparison: `-1` (unlimited) is always better than any positive
1729
+ * number. Otherwise higher = better. Missing keys are treated as 0.
1730
+ *
1731
+ * Pricing can be `null` (the bundle only has override pricing); a switch
1732
+ * from value ↔ null is classified as REGRESSION (value dropped) or IMPROVEMENT
1733
+ * (value added, lowerIsBetter inverted). Both null
1734
+ * stay NEUTRAL.
1735
+ */
1736
+ declare function classifyBundleVersionDiff(oldV: BundleVersionFields, newV: BundleVersionFields): DiffResult;
1737
+
1738
+ interface PromoPreviewRequest {
1739
+ /** Promo code (case-insensitive, normalized to uppercase in the service). */
1740
+ code: string;
1741
+ /** Plan ID the code is validated against. */
1742
+ plan: PlanId;
1743
+ billingCycle: BillingCycle;
1744
+ /** Optional — for the firstTimeCustomersOnly check. */
1745
+ email?: string;
1746
+ }
1747
+ /**
1748
+ * Wire format of the service response. Decimals as string (two decimal places),
1749
+ * dates as ISO-8601 string. Corresponds 1:1 to the service return type
1750
+ * `PreviewResult` (@saasicat/nest/promo).
1751
+ */
1752
+ type PromoPreviewResponse = {
1753
+ valid: false;
1754
+ reason: PromoPreviewInvalidReason;
1755
+ } | PromoPreviewValidResponse;
1756
+ type PromoPreviewInvalidReason = 'NOT_FOUND' | 'EXPIRED' | 'EXHAUSTED' | 'PAUSED' | 'PLAN_MISMATCH' | 'BILLING_MISMATCH' | 'BELOW_MINIMUM_AMOUNT' | 'WOULD_PRODUCE_ZERO_INVOICE' | 'NOT_FIRST_TIME_CUSTOMER' | 'RATE_LIMITED';
1757
+ interface PromoPreviewValidResponse {
1758
+ valid: true;
1759
+ code: string;
1760
+ label: string;
1761
+ discount: {
1762
+ valueType: 'PERCENT' | 'ABSOLUTE';
1763
+ /** Decimal-as-string. */
1764
+ value: string;
1765
+ durationType: 'ONCE' | 'MONTHS' | 'BILLING_CYCLES';
1766
+ durationValue: number | null;
1767
+ };
1768
+ price: {
1769
+ /** Decimal-as-string, e.g. "199.00". */
1770
+ originalGross: string;
1771
+ discountGross: string;
1772
+ discountedGross: string;
1773
+ includedVat: string;
1774
+ nextRegularAmountGross: string;
1775
+ /** ISO date from which the regular price applies, or null for ONCE. */
1776
+ regularStartsAt: string | null;
1777
+ };
1778
+ }
1779
+ interface OnboardingSelectionRequest {
1780
+ plan: PlanId;
1781
+ billingCycle: BillingCycle;
1782
+ /**
1783
+ * Optional: live BundleVersion IDs of independently bookable bundles.
1784
+ * These are booked best-effort by the backend after the plan setup.
1785
+ */
1786
+ bundleVersionIds?: string[];
1787
+ /** Optional — if set, the code is redeemed atomically with the plan selection. */
1788
+ promoCode?: string;
1789
+ }
1790
+ interface OnboardingSelectionResponse {
1791
+ plan: PlanId;
1792
+ billingCycle: BillingCycle;
1793
+ /**
1794
+ * Number of bundles actually booked (P11.7.3). Bundles are added
1795
+ * best-effort **after** the plan change — failed bookings end up as
1796
+ * warnings without rolling back the plan change.
1797
+ */
1798
+ bundlesAdded: number;
1799
+ /**
1800
+ * Promo redemption — `null` if no code was sent or the redemption
1801
+ * failed (plan change + bundles are then persisted anyway; the UI shows
1802
+ * a hint and lets the tenant redeem the code later via
1803
+ * `POST /billing/promo/redeem`).
1804
+ */
1805
+ promoRedemption: OnboardingPromoRedemption | null;
1806
+ /**
1807
+ * Additional quota hints or warnings the service produced during
1808
+ * onboarding (e.g. plan-downgrade blockers that were not applied, or
1809
+ * bundle-booking errors). Empty on success without anomalies.
1810
+ */
1811
+ warnings: string[];
1812
+ }
1813
+ interface OnboardingPromoRedemption {
1814
+ code: string;
1815
+ discount: {
1816
+ valueType: 'PERCENT' | 'ABSOLUTE';
1817
+ value: string;
1818
+ durationType: 'ONCE' | 'MONTHS' | 'BILLING_CYCLES';
1819
+ durationValue: number | null;
1820
+ };
1821
+ /** ISO date. */
1822
+ startsAt: string;
1823
+ /** ISO date or null for ONCE. */
1824
+ endsAt: string | null;
1825
+ }
1826
+
1827
+ interface PlanRow {
1828
+ id: string;
1829
+ projectKey: string;
1830
+ planKey: string;
1831
+ label: string;
1832
+ description: string | null;
1833
+ icon: string | null;
1834
+ sortOrder: number;
1835
+ createdAt: string;
1836
+ updatedAt: string;
1837
+ deletedAt: string | null;
1838
+ }
1839
+ /**
1840
+ * Fields that must be set when creating a new plan stem. `id`, `createdAt`,
1841
+ * `updatedAt`, `deletedAt` are assigned by the repository. PlanVersion-specific
1842
+ * fields (features, quotas, pricing) belong in a separate `PlanVersion`
1843
+ * creation (follows in M6 Pack 2).
1844
+ */
1845
+ interface CreatePlanData {
1846
+ projectKey: string;
1847
+ planKey: string;
1848
+ label: string;
1849
+ description?: string | null;
1850
+ icon?: string | null;
1851
+ sortOrder?: number;
1852
+ }
1853
+ /**
1854
+ * Fields that may be changed on the plan stem. `planKey` and `projectKey`
1855
+ * are deliberately not here — stem identity is immutable; whoever wants to
1856
+ * change it creates a new plan and retires the old one.
1857
+ */
1858
+ interface UpdatePlanData {
1859
+ label?: string;
1860
+ description?: string | null;
1861
+ icon?: string | null;
1862
+ sortOrder?: number;
1863
+ }
1864
+
1865
+ /**
1866
+ * PlanVersion — versioned plan definition (`BASIC v3`, `STANDARD v7`, …).
1867
+ *
1868
+ * Quotas: the platform convention is `quotas: { users: 10, vehicles: 50, … }`.
1869
+ * Legacy backends ship flat fields (`maxUsers`, `maxVehicles`,
1870
+ * `maxStorageGb`); these are marked optional and are tolerated by the
1871
+ * lift-and-shift catalog-builder layer. An index signature allows further
1872
+ * app-specific fields.
1873
+ */
1874
+ interface PlanVersionRow extends VersionedEntityBase {
1875
+ planId: PlanId;
1876
+ features: FeatureKey[];
1877
+ /**
1878
+ * Bundle selection assembled in the editor (bundleKeys,
1879
+ * SCREAMING_SNAKE_CASE). A bundle in this list implies that all of its
1880
+ * features are also contained in `features` — bundles are marketing
1881
+ * groupings of features. Persisted so the editor can reconstruct the
1882
+ * original bundle selection and the public catalog can present the plan
1883
+ * as a bundle. Optional, because consumer backends add the column
1884
+ * additively — if it is missing, the selection is empty and the editor
1885
+ * derives fully-active bundles from `features`.
1886
+ */
1887
+ bundles?: string[];
1888
+ quotas?: Record<QuotaKey, number>;
1889
+ monthlyNet: string;
1890
+ yearlyNet: string;
1891
+ marketed: boolean;
1892
+ /**
1893
+ * End date explicitly set by the SuperAdmin for a live PlanVersion.
1894
+ * Null = no end date, runs indefinitely until superseded by a successor
1895
+ * version (auto-succession then sets `supersededAt`).
1896
+ *
1897
+ * Unlike `validUntil` (auto-succession, maintained by the service),
1898
+ * `endsAt` is user-initiated: `POST /admin/catalog/plan-versions/:id/terminate`
1899
+ * sets the field. When `endsAt < NOW()` the version is no longer live for
1900
+ * new bookings — existing subscriptions (P1) stay bound.
1901
+ *
1902
+ * Optional, because consumer backends add the column additively — if it
1903
+ * is missing, there is no end date.
1904
+ */
1905
+ endsAt?: string | null;
1906
+ /** @deprecated Read from `quotas['users']` once available. */
1907
+ maxUsers?: number;
1908
+ /** @deprecated Legacy field; read from `quotas['vehicles']`. */
1909
+ maxVehicles?: number;
1910
+ /** @deprecated Read from `quotas['storageGb']`. */
1911
+ maxStorageGb?: number;
1912
+ }
1913
+
1914
+ interface UpsertResult {
1915
+ created: boolean;
1916
+ /** If `created=false`, a short reason is here (e.g. "exists"). */
1917
+ skipReason?: string;
1918
+ }
1919
+ interface UpsertPlanInput {
1920
+ projectKey: string;
1921
+ planKey: string;
1922
+ label: string;
1923
+ description?: string | null;
1924
+ sortOrder?: number;
1925
+ }
1926
+ interface UpsertPlanVersionInput {
1927
+ /** planKey, not the plan UUID — the sink resolves internally. */
1928
+ planKey: string;
1929
+ version: number;
1930
+ features: FeatureKey[];
1931
+ quotas: Record<QuotaKey, number>;
1932
+ monthlyNet: string;
1933
+ yearlyNet: string;
1934
+ marketed: boolean;
1935
+ /** For the importer: always published=true (`publishedAt = NOW`). */
1936
+ publish: boolean;
1937
+ changeNote: string;
1938
+ }
1939
+ interface UpsertFeatureCatalogEntryInput {
1940
+ projectKey: string;
1941
+ featureKey: FeatureKey;
1942
+ label?: string;
1943
+ icon?: string;
1944
+ tier?: string;
1945
+ plannedOnly?: boolean;
1946
+ core?: boolean;
1947
+ }
1948
+ /**
1949
+ * Adapter port for the plan catalog importer. Apps implement it
1950
+ * against their Prisma client (or another persistence stack).
1951
+ */
1952
+ interface PlanCatalogImportSink {
1953
+ upsertPlan(input: UpsertPlanInput): Promise<UpsertResult>;
1954
+ upsertPlanVersion(input: UpsertPlanVersionInput): Promise<UpsertResult>;
1955
+ upsertFeatureCatalogEntry(input: UpsertFeatureCatalogEntryInput): Promise<UpsertResult>;
1956
+ }
1957
+ interface PlanCatalogImportReport {
1958
+ plansCreated: number;
1959
+ plansSkipped: number;
1960
+ planVersionsCreated: number;
1961
+ planVersionsSkipped: number;
1962
+ featureEntriesCreated: number;
1963
+ featureEntriesSkipped: number;
1964
+ /** Warnings (non-fatal, e.g. "feature without label"). */
1965
+ warnings: string[];
1966
+ }
1967
+
1968
+ interface PlanCatalogReadSnapshot {
1969
+ /** Plan stems (deletedAt IS NULL). */
1970
+ plans: PlanRow[];
1971
+ /**
1972
+ * Live PlanVersions: per `planId` (= planKey) the currently published
1973
+ * version (publishedAt IS NOT NULL AND supersededAt IS NULL).
1974
+ * Apps with plans without a live version take over the plan, but without
1975
+ * pricing/quotas — the importer probably emitted a warning.
1976
+ */
1977
+ livePlanVersions: PlanVersionRow[];
1978
+ /** Feature catalog entries (deletedAt IS NULL). */
1979
+ featureEntries: FeatureCatalogEntryRow[];
1980
+ }
1981
+ /**
1982
+ * Adapter port for the DB-based PlanCatalog assembly. Apps
1983
+ * implement it against their Prisma tables.
1984
+ */
1985
+ interface PlanCatalogReadSink {
1986
+ loadSnapshot(projectKey: string): Promise<PlanCatalogReadSnapshot>;
1987
+ }
1988
+
1989
+ /**
1990
+ * Fields of a new PlanVersion in draft status (`publishedAt = null`).
1991
+ * Created by the SuperAdmin, later published via `publishPlanVersion()`.
1992
+ * Only **one** draft version per `planId` is allowed
1993
+ * (partial unique index in the migration).
1994
+ */
1995
+ interface CreatePlanVersionDraftData {
1996
+ /**
1997
+ * **planKey** (e.g. "STARTER"), not the plan UUID. The service
1998
+ * resolves the plan UUID from the controller path param to
1999
+ * planKey beforehand. Adapters may store that semantic key directly or
2000
+ * translate it to a normalized `Plan.id` foreign key.
2001
+ */
2002
+ planId: string;
2003
+ /** Predecessor version the diff is computed against (null for v1). */
2004
+ baseVersionId?: string | null;
2005
+ features: FeatureKey[];
2006
+ /** Bundle selection (bundleKeys). Default empty. See `PlanVersionRow.bundles`. */
2007
+ bundles?: string[];
2008
+ quotas: Record<QuotaKey, number>;
2009
+ monthlyNet: string;
2010
+ yearlyNet: string;
2011
+ marketed?: boolean;
2012
+ /** Required on publish (contract protection P3). */
2013
+ changeNote?: string;
2014
+ /** Optional in the draft (required on publish). ISO date string. */
2015
+ validFrom?: string | null;
2016
+ /** Optional; null = valid indefinitely. ISO date string. */
2017
+ validUntil?: string | null;
2018
+ createdByUserId?: string | null;
2019
+ }
2020
+ /**
2021
+ * Fields of a draft PlanVersion that may still be changed.
2022
+ * After `publishedAt` the version becomes immutable (contract protection P1/P4).
2023
+ */
2024
+ interface UpdatePlanVersionDraftData {
2025
+ features?: FeatureKey[];
2026
+ /** Bundle selection (bundleKeys). See `PlanVersionRow.bundles`. */
2027
+ bundles?: string[];
2028
+ quotas?: Record<QuotaKey, number>;
2029
+ monthlyNet?: string;
2030
+ yearlyNet?: string;
2031
+ marketed?: boolean;
2032
+ changeNote?: string;
2033
+ validFrom?: string | null;
2034
+ validUntil?: string | null;
2035
+ }
2036
+ /**
2037
+ * Input for `publishPlanVersion()`. The service computes `nonRegressive` and
2038
+ * `publishedChanges` from the diff to the predecessor version (
2039
+ * §7); the caller only provides confirmation + user tag.
2040
+ *
2041
+ * `validFrom` is **required** on publish. If the draft
2042
+ * already has a `validFrom`, it is optional here. Auto-succession sets
2043
+ * `validUntil` of the predecessor version.
2044
+ */
2045
+ interface PublishPlanVersionData {
2046
+ publishedByUserId: string | null;
2047
+ /**
2048
+ * If true and the diff classifies the version as regressive,
2049
+ * it is published anyway (bulk-publish MFA confirmation,
2050
+ */
2051
+ forceRegressive?: boolean;
2052
+ /**
2053
+ * Allows publishing despite a price of 0.00. Default false: a 0.00 publish
2054
+ * is blocked to prevent accidentally going live with seed placeholders.
2055
+ * Only for deliberately free special contracts (e.g. ENTERPRISE).
2056
+ */
2057
+ allowZeroPrice?: boolean;
2058
+ /**
2059
+ * Required on publish if the draft has no `validFrom`. Must lie
2060
+ * strictly after the `validFrom` of the predecessor version.
2061
+ */
2062
+ validFrom?: string | null;
2063
+ /**
2064
+ * Optional; null = valid indefinitely (fits the last version
2065
+ * of a plan). Automatically overwritten by the service when a
2066
+ * successor version is created.
2067
+ */
2068
+ validUntil?: string | null;
2069
+ }
2070
+ /**
2071
+ * Service result for mutating PlanVersion operations
2072
+ * (createDraft, updateDraft, publish): returns the persisted row plus
2073
+ * a list of strict-mode warnings. In `warn-only` mode → banner in the UI;
2074
+ * in `blocking` mode the service throws HTTP 422 instead with
2075
+ * the same warning list.
2076
+ */
2077
+ interface PlanVersionMutationResult {
2078
+ planVersion: PlanVersionRow;
2079
+ warnings: StrictModeWarning[];
2080
+ }
2081
+
2082
+ type PlatformRole = 'SUPER_ADMIN' | 'TENANT_ADMIN' | 'TENANT_MEMBER';
2083
+ interface TenantDto {
2084
+ id: string;
2085
+ slug: string;
2086
+ name: string;
2087
+ isActive: boolean;
2088
+ settings?: Record<string, unknown>;
2089
+ deletedAt: string | null;
2090
+ }
2091
+ interface CreateTenantInput {
2092
+ slug: string;
2093
+ name: string;
2094
+ settings?: Record<string, unknown>;
2095
+ }
2096
+ interface TenantListFilter {
2097
+ status?: 'active' | 'suspended' | 'deleted';
2098
+ plan?: string;
2099
+ search?: string;
2100
+ page?: number;
2101
+ pageSize?: number;
2102
+ }
2103
+ interface Paginated<T> {
2104
+ items: T[];
2105
+ page: number;
2106
+ pageSize: number;
2107
+ total: number;
2108
+ }
2109
+ interface PlatformUserDto {
2110
+ id: string;
2111
+ email: string;
2112
+ firstName?: string;
2113
+ lastName?: string;
2114
+ platformRole: PlatformRole;
2115
+ isActive: boolean;
2116
+ lastLoginAt: string | null;
2117
+ deletedAt: string | null;
2118
+ }
2119
+ interface UserListFilter {
2120
+ search?: string;
2121
+ role?: PlatformRole;
2122
+ page?: number;
2123
+ pageSize?: number;
2124
+ }
2125
+ /** Adapter to the project's own tenant schema. */
2126
+ interface TenantPort {
2127
+ findById(id: string): Promise<TenantDto | null>;
2128
+ findBySlug(slug: string): Promise<TenantDto | null>;
2129
+ list(filter: TenantListFilter): Promise<Paginated<TenantDto>>;
2130
+ create(input: CreateTenantInput): Promise<TenantDto>;
2131
+ setActive(id: string, active: boolean, reason: string): Promise<void>;
2132
+ softDelete(id: string, reason: string): Promise<void>;
2133
+ }
2134
+ /** Adapter to the project's own user schema. */
2135
+ interface UserPort {
2136
+ findById(id: string): Promise<PlatformUserDto | null>;
2137
+ findByEmail(email: string): Promise<PlatformUserDto | null>;
2138
+ countActive(tenantId: string): Promise<number>;
2139
+ listForTenant(tenantId: string, filter: UserListFilter): Promise<Paginated<PlatformUserDto>>;
2140
+ resetPassword(userId: string, newHash: string): Promise<void>;
2141
+ hasRole(userId: string, role: PlatformRole): Promise<boolean>;
2142
+ }
2143
+ interface CreateSuperAdminCliInput {
2144
+ email: string;
2145
+ /**
2146
+ * Plaintext password. The adapter hashes it with the app's own method
2147
+ * (argon2/bcrypt) — hashing therefore stays app-specific, the shared
2148
+ * command doesn't know the algorithm.
2149
+ */
2150
+ password: string;
2151
+ firstName?: string;
2152
+ lastName?: string;
2153
+ }
2154
+ interface ReassignTenantAdminCliResult {
2155
+ user: PlatformUserDto;
2156
+ /** true if a new emergency admin was created (instead of a promotion). */
2157
+ created: boolean;
2158
+ /** Previous role on promotion; null on new creation. */
2159
+ previousRole: PlatformRole | null;
2160
+ /** On new creation: generated initial password that the admin passes on. */
2161
+ oneTimePassword?: string;
2162
+ }
2163
+ interface PasswordResetCliResult {
2164
+ user: PlatformUserDto;
2165
+ /**
2166
+ * If the app generates a one-time password (instead of an OTP/reset email),
2167
+ * it's returned here so the command can output it out-of-band.
2168
+ */
2169
+ oneTimePassword?: string;
2170
+ }
2171
+ interface CliUserRow {
2172
+ email: string;
2173
+ role: PlatformRole;
2174
+ status: string;
2175
+ lastLoginAt: string | null;
2176
+ }
2177
+ /**
2178
+ * Write/list operations for the shared `<app> user` CLI command. Separates
2179
+ * the app-specific schema mutations (password hashing, role mapping,
2180
+ * tenant relationship) from the generic command flow (identity/MFA/audit/output).
2181
+ * Consumers register an implementation via
2182
+ * `CliContextModule.forRoot({ userManagementPort })`.
2183
+ */
2184
+ /**
2185
+ * Narrow port for the first-run setup (interface segregation): ONLY
2186
+ * an existence check + creation of the first SUPER_ADMIN. The `SetupModule` depends
2187
+ * solely on this — a consumer that only wants the setup wizard doesn't need to
2188
+ * implement tenant user management.
2189
+ */
2190
+ interface SuperAdminProvisioningPort {
2191
+ /** Number of active SUPER_ADMIN users — basis for the first-run setup guard. */
2192
+ countSuperAdmins(): Promise<number>;
2193
+ /** Creates a new SUPER_ADMIN; throws `PlatformUserExistsError` if the email exists. */
2194
+ createSuperAdmin(input: CreateSuperAdminCliInput): Promise<PlatformUserDto>;
2195
+ }
2196
+ interface UserManagementPort extends SuperAdminProvisioningPort {
2197
+ /** Promotes an existing user to TENANT_ADMIN or creates an emergency admin. */
2198
+ reassignTenantAdmin(tenantSlug: string, email: string): Promise<ReassignTenantAdminCliResult>;
2199
+ /** Lists a tenant's users (by slug) for `<app> user list`. */
2200
+ listTenantUsers(tenantSlug: string): Promise<CliUserRow[]>;
2201
+ /** Triggers the app's own password reset (one-time password or OTP email). */
2202
+ triggerPasswordReset(email: string): Promise<PasswordResetCliResult>;
2203
+ /** Deactivates a user (app-specific status). */
2204
+ deactivate(email: string, reason: string): Promise<PlatformUserDto>;
2205
+ }
2206
+ /** Returns current usage for a limit dimension. */
2207
+ interface QuotaProvider {
2208
+ /** quotaKey, as declared via `@DefinesQuota({ key })`. */
2209
+ readonly key: string;
2210
+ count(tenantId: string): Promise<number>;
2211
+ /** Optional: cache TTL in seconds (default 30s). */
2212
+ readonly cacheTtlSeconds?: number;
2213
+ }
2214
+ /**
2215
+ * Password hashing adapter. The algorithm (argon2/bcrypt) stays app-specific;
2216
+ * platform flows that persist credentials (registration, SuperAdmin
2217
+ * bootstrap) hash through this port instead of choosing an algorithm.
2218
+ */
2219
+ interface PasswordHasher {
2220
+ hash(plain: string): Promise<string>;
2221
+ verify(hash: string, plain: string): Promise<boolean>;
2222
+ }
2223
+ /** Adapter for MFA secret persistence. */
2224
+ interface MfaPort {
2225
+ /** Returns the stored TOTP secret or null. */
2226
+ getSecret(userId: string): Promise<string | null>;
2227
+ /** Persists or deletes (null) the TOTP secret. */
2228
+ setSecret(userId: string, secret: string | null): Promise<void>;
2229
+ /** The platform calls this during the mfa-setup command. */
2230
+ isEnabled(userId: string): Promise<boolean>;
2231
+ }
2232
+ /**
2233
+ * Opaque transaction context. The consumer determines the concrete type
2234
+ * (e.g. `Prisma.TransactionClient`). Platform code only passes it
2235
+ * through — no content inspection.
2236
+ */
2237
+ type TransactionContext = unknown;
2238
+ /**
2239
+ * Transaction runner — wrapper over `prisma.$transaction`,
2240
+ * Django `transaction.atomic`, etc.
2241
+ */
2242
+ interface TransactionRunner {
2243
+ run<T>(fn: (tx: TransactionContext) => Promise<T>): Promise<T>;
2244
+ }
2245
+
2246
+ /**
2247
+ * Snapshot of a `PromoCode` row for service-layer calls. Decimals as
2248
+ * strings (`value`, `minimumPlanAmountGross`) — the service parses them to
2249
+ * `number` for calculations, the consumer adapter maps from its
2250
+ * `Prisma.Decimal` (toString()).
2251
+ */
2252
+ interface PromoCodeRecord {
2253
+ id: string;
2254
+ code: string;
2255
+ valueType: PromoCodeValueType;
2256
+ /** Decimal-as-string (e.g. "25.00"). */
2257
+ value: string;
2258
+ durationType: PromoCodeDurationType;
2259
+ durationValue: number | null;
2260
+ validFrom: Date | null;
2261
+ validUntil: Date | null;
2262
+ maxRedemptions: number | null;
2263
+ redemptionsCount: number;
2264
+ appliesToPlans: string[];
2265
+ appliesToBilling: BillingCycle | null;
2266
+ firstTimeCustomersOnly: boolean;
2267
+ /** Decimal-as-string or null. */
2268
+ minimumPlanAmountGross: string | null;
2269
+ allowZeroInvoice: boolean;
2270
+ status: PromoCodeStatus;
2271
+ description: string | null;
2272
+ campaignTag: string | null;
2273
+ revenueDeductionAccount: string | null;
2274
+ createdById: string | null;
2275
+ createdAt: Date;
2276
+ updatedAt: Date;
2277
+ deletedAt: Date | null;
2278
+ }
2279
+ /** Snapshot of a `PromoCodeRedemption` row. */
2280
+ interface PromoCodeRedemptionRecord {
2281
+ id: string;
2282
+ promoCodeId: string;
2283
+ subscriptionId: string;
2284
+ tenantId: string;
2285
+ appliedValueType: PromoCodeValueType;
2286
+ appliedValue: string;
2287
+ appliedDurationType: PromoCodeDurationType;
2288
+ appliedDurationValue: number | null;
2289
+ startsAt: Date;
2290
+ endsAt: Date | null;
2291
+ status: PromoCodeRedemptionStatus;
2292
+ redeemedAt: Date;
2293
+ reversedAt: Date | null;
2294
+ }
2295
+ /** Input for `PromoCodesService.create()`. */
2296
+ interface CreatePromoCodeData {
2297
+ code: string;
2298
+ valueType: PromoCodeValueType;
2299
+ /** Numeric — the service serializes to a decimal string. */
2300
+ value: number;
2301
+ durationType: PromoCodeDurationType;
2302
+ durationValue?: number | null;
2303
+ validFrom?: Date | null;
2304
+ validUntil?: Date | null;
2305
+ maxRedemptions?: number | null;
2306
+ appliesToPlans?: string[];
2307
+ appliesToBilling?: BillingCycle | null;
2308
+ firstTimeCustomersOnly?: boolean;
2309
+ minimumPlanAmountGross?: number | null;
2310
+ allowZeroInvoice?: boolean;
2311
+ description?: string | null;
2312
+ campaignTag?: string | null;
2313
+ revenueDeductionAccount?: string | null;
2314
+ createdById: string;
2315
+ }
2316
+ /** Input for `PromoCodesService.update()`. */
2317
+ interface UpdatePromoCodeData {
2318
+ status?: PromoCodeStatus;
2319
+ valueType?: PromoCodeValueType;
2320
+ value?: number;
2321
+ durationType?: PromoCodeDurationType;
2322
+ durationValue?: number | null;
2323
+ validFrom?: Date | null;
2324
+ description?: string | null;
2325
+ validUntil?: Date | null;
2326
+ maxRedemptions?: number | null;
2327
+ appliesToPlans?: string[];
2328
+ appliesToBilling?: BillingCycle | null;
2329
+ firstTimeCustomersOnly?: boolean;
2330
+ minimumPlanAmountGross?: number | null;
2331
+ allowZeroInvoice?: boolean;
2332
+ campaignTag?: string | null;
2333
+ revenueDeductionAccount?: string | null;
2334
+ }
2335
+ /** Filter for `PromoCodesService.findAll()`. */
2336
+ interface PromoCodeFilter {
2337
+ status?: PromoCodeStatus;
2338
+ campaignTag?: string;
2339
+ /** Substring search in the code (case-insensitive on UPPERCASE). */
2340
+ search?: string;
2341
+ }
2342
+ /** Entry for `PromoCodeRedemptionRepository.listByPromoCode()`. */
2343
+ interface PromoCodeRedemptionListItem extends PromoCodeRedemptionRecord {
2344
+ tenant?: {
2345
+ id: string;
2346
+ name: string;
2347
+ slug: string;
2348
+ } | null;
2349
+ }
2350
+ /**
2351
+ * Adapter for PromoCode persistence. Atomic slot reservation lives in the
2352
+ * adapter because it is DB-specific (Postgres `UPDATE ... WHERE ... AND
2353
+ * (maxRedemptions IS NULL OR redemptionsCount < maxRedemptions)`).
2354
+ */
2355
+ interface PromoCodeRepository {
2356
+ findById(id: string): Promise<PromoCodeRecord | null>;
2357
+ findByCode(code: string, tx?: TransactionContext): Promise<PromoCodeRecord | null>;
2358
+ findMany(filter: PromoCodeFilter): Promise<PromoCodeRecord[]>;
2359
+ create(data: CreatePromoCodeData): Promise<PromoCodeRecord>;
2360
+ update(id: string, data: UpdatePromoCodeData): Promise<PromoCodeRecord>;
2361
+ softDelete(id: string): Promise<void>;
2362
+ /**
2363
+ * Atomic slot reservation: increments `redemptionsCount` and checks
2364
+ * `status === 'ACTIVE' && (maxRedemptions IS NULL || redemptionsCount < maxRedemptions)`.
2365
+ * Returns true if the slot was reserved, false if EXHAUSTED
2366
+ * or the status is not ACTIVE.
2367
+ */
2368
+ claimSlot(id: string, tx?: TransactionContext): Promise<boolean>;
2369
+ /** Sets the status to `EXHAUSTED` when `redemptionsCount >= maxRedemptions`. */
2370
+ markExhaustedIfFull(id: string, tx?: TransactionContext): Promise<void>;
2371
+ /** Decrements `redemptionsCount` by 1 (min 0); EXHAUSTED → ACTIVE. */
2372
+ releaseSlot(id: string, tx?: TransactionContext): Promise<void>;
2373
+ /**
2374
+ * Bulk-expire cron: sets all codes with `validUntil < now` and status
2375
+ * ACTIVE/PAUSED to EXPIRED. Returns: number of updated rows.
2376
+ */
2377
+ expireDueCodes(now: Date): Promise<number>;
2378
+ }
2379
+ /** Adapter for PromoCodeRedemption persistence. */
2380
+ interface PromoCodeRedemptionRepository {
2381
+ findBySubscription(subscriptionId: string, tx?: TransactionContext): Promise<PromoCodeRedemptionRecord | null>;
2382
+ create(data: Omit<PromoCodeRedemptionRecord, 'id' | 'redeemedAt' | 'status' | 'reversedAt'>, tx?: TransactionContext): Promise<PromoCodeRedemptionRecord>;
2383
+ setReversed(id: string, tx?: TransactionContext): Promise<PromoCodeRedemptionRecord>;
2384
+ countByPromoCode(promoCodeId: string, status?: PromoCodeRedemptionStatus): Promise<number>;
2385
+ listByPromoCode(promoCodeId: string): Promise<PromoCodeRedemptionListItem[]>;
2386
+ expireDueRedemptions(now: Date): Promise<number>;
2387
+ }
2388
+ /** Adapter for `PromoCodeValidationLog` writes. */
2389
+ interface PromoCodeValidationLogRepository {
2390
+ log(args: {
2391
+ promoCodeId: string | null;
2392
+ codeAttempt: string;
2393
+ result: string;
2394
+ ipHash?: string;
2395
+ sessionId?: string;
2396
+ }): Promise<void>;
2397
+ /** Number of `result = 'VALID'` logs for a promo code. */
2398
+ countValid(promoCodeId: string): Promise<number>;
2399
+ }
2400
+ /**
2401
+ * First-time-customer check for the `firstTimeCustomersOnly` eligibility.
2402
+ * The consumer implementation decides what "first time" means. Important:
2403
+ * unfinished onboarding drafts must not count as an existing customer.
2404
+ */
2405
+ interface FirstTimeCustomerCheck {
2406
+ /** Returns true if a completed/historical customer already exists for the email. */
2407
+ hasExistingCustomerForEmail(email: string): Promise<boolean>;
2408
+ }
2409
+ /** Subscription lookup for `redeem()`. Sufficient for promo calculations. */
2410
+ interface PromoSubscriptionLookup {
2411
+ findById(subscriptionId: string, tx?: TransactionContext): Promise<{
2412
+ id: string;
2413
+ tenantId: string;
2414
+ plan: string;
2415
+ billingCycle: BillingCycle;
2416
+ startedAt: Date | null;
2417
+ } | null>;
2418
+ }
2419
+ /**
2420
+ * Aggregation adapter for the stats endpoint (`PromoCodesService.stats`).
2421
+ * Consumers without an `InvoiceDiscount` table return '0.00'.
2422
+ */
2423
+ interface PromoRevenueDeductionAggregator {
2424
+ /** Sum of the amountGross values for all redemptions of a promo code (Decimal-as-string). */
2425
+ sumGrossForPromoCode(promoCodeId: string): Promise<string>;
2426
+ }
2427
+
2428
+ type ContractLineItemKind = 'plan' | 'bundle' | 'discount';
2429
+ type SubscriptionContractStatus = 'active' | 'scheduled' | 'terminated' | 'superseded';
2430
+ interface ContractLineItemRecord {
2431
+ id: string;
2432
+ contractId: string;
2433
+ kind: ContractLineItemKind;
2434
+ sourceKey: string;
2435
+ sourceVersionId: string | null;
2436
+ titleSnapshot: string;
2437
+ descriptionSnapshot: string | null;
2438
+ quantity: number;
2439
+ unit: string | null;
2440
+ priceNet: number;
2441
+ priceGross: number;
2442
+ billingCycle: 'monthly' | 'yearly';
2443
+ minimumTermUntil: Date | null;
2444
+ featuresSnapshot: string[];
2445
+ quotaEffectsSnapshot: Record<string, number>;
2446
+ metadata: Record<string, unknown> | null;
2447
+ createdAt: Date;
2448
+ }
2449
+ interface SubscriptionContractPriceSnapshot {
2450
+ currency: string;
2451
+ billingCycle: 'monthly' | 'yearly';
2452
+ subtotalNet: number;
2453
+ discountNet: number;
2454
+ totalNet: number;
2455
+ vatRate: number;
2456
+ totalGross: number;
2457
+ }
2458
+ interface SubscriptionContractRecord {
2459
+ id: string;
2460
+ projectKey: string;
2461
+ tenantId: string;
2462
+ status: SubscriptionContractStatus;
2463
+ effectiveFrom: Date;
2464
+ effectiveUntil: Date | null;
2465
+ originalOfferId: string | null;
2466
+ originalPlanVersionId: string | null;
2467
+ originalBundleVersionIds: string[];
2468
+ entitlementSnapshot: EffectiveLimitsSnapshot | null;
2469
+ priceSnapshot: SubscriptionContractPriceSnapshot;
2470
+ promotionSnapshots: unknown[];
2471
+ promoCodeSnapshots: unknown[];
2472
+ termsSnapshot: Record<string, unknown> | null;
2473
+ lineItems: ContractLineItemRecord[];
2474
+ createdAt: Date;
2475
+ updatedAt: Date;
2476
+ }
2477
+ type NewContractLineItemData = Omit<ContractLineItemRecord, 'id' | 'contractId' | 'createdAt'>;
2478
+ interface CreateSubscriptionContractData {
2479
+ projectKey: string;
2480
+ tenantId: string;
2481
+ status?: SubscriptionContractStatus;
2482
+ effectiveFrom: Date;
2483
+ effectiveUntil?: Date | null;
2484
+ originalOfferId?: string | null;
2485
+ originalPlanVersionId?: string | null;
2486
+ originalBundleVersionIds?: string[];
2487
+ entitlementSnapshot?: EffectiveLimitsSnapshot | null;
2488
+ priceSnapshot: SubscriptionContractPriceSnapshot;
2489
+ promotionSnapshots?: unknown[];
2490
+ promoCodeSnapshots?: unknown[];
2491
+ termsSnapshot?: Record<string, unknown> | null;
2492
+ lineItems: NewContractLineItemData[];
2493
+ }
2494
+ interface TerminateSubscriptionContractData {
2495
+ effectiveUntil: Date;
2496
+ status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'>;
2497
+ }
2498
+ interface SubscriptionContractFilter {
2499
+ projectKey?: string;
2500
+ tenantId?: string;
2501
+ status?: SubscriptionContractStatus;
2502
+ asOf?: Date;
2503
+ }
2504
+ interface InvoiceLineItemSnapshot {
2505
+ sourceContractLineItemId: string;
2506
+ sourceKey: string;
2507
+ sourceVersionId: string | null;
2508
+ kind: ContractLineItemKind;
2509
+ title: string;
2510
+ description: string | null;
2511
+ quantity: number;
2512
+ unit: string | null;
2513
+ priceNet: number;
2514
+ priceGross: number;
2515
+ billingCycle: 'monthly' | 'yearly';
2516
+ minimumTermUntil: Date | null;
2517
+ metadata: Record<string, unknown> | null;
2518
+ }
2519
+ interface SubscriptionContractInvoiceSnapshot {
2520
+ contractId: string;
2521
+ projectKey: string;
2522
+ tenantId: string;
2523
+ originalOfferId: string | null;
2524
+ currency: string;
2525
+ billingCycle: 'monthly' | 'yearly';
2526
+ effectiveFrom: Date;
2527
+ effectiveUntil: Date | null;
2528
+ subtotalNet: number;
2529
+ discountNet: number;
2530
+ totalNet: number;
2531
+ vatRate: number;
2532
+ totalGross: number;
2533
+ lineItems: InvoiceLineItemSnapshot[];
2534
+ }
2535
+
2536
+ /**
2537
+ * Snapshot form of a `Subscription` row for the EntitlementService
2538
+ * computation. The consumer maps its Prisma structure onto this form.
2539
+ */
2540
+ interface SubscriptionRecord {
2541
+ id: string;
2542
+ tenantId: string;
2543
+ plan: string;
2544
+ status: string;
2545
+ isPilot?: boolean;
2546
+ trialEntitlementPlan?: string | null;
2547
+ pendingPlan?: string | null;
2548
+ pendingEffectiveAt?: Date | null;
2549
+ customLimits?: {
2550
+ quotas?: Record<string, number>;
2551
+ features?: string[];
2552
+ } | null;
2553
+ planVersionId: string;
2554
+ planVersion: PlanVersionRecord;
2555
+ }
2556
+ /** Snapshot of a `PlanVersion` row. */
2557
+ interface PlanVersionRecord {
2558
+ planId: string;
2559
+ quotas: Record<string, number>;
2560
+ features: string[];
2561
+ }
2562
+ /**
2563
+ * Read adapter for subscriptions. The consumer implementation loads from its
2564
+ * own `Subscription` table incl. eager-loaded `planVersion`
2565
+ * and maps to `SubscriptionRecord`.
2566
+ */
2567
+ interface SubscriptionRepository {
2568
+ /** Returns a tenant's subscription or null. */
2569
+ findByTenantId(tenantId: string): Promise<SubscriptionRecord | null>;
2570
+ /**
2571
+ * Like `findByTenantId`, but within the transaction with a row lock
2572
+ * (`SELECT ... FOR UPDATE`). Used by the transactional `enforceLimit`
2573
+ * path to serialize concurrent creations on the same tenant.
2574
+ */
2575
+ findByTenantIdLocked(tenantId: string, tx: TransactionContext): Promise<SubscriptionRecord | null>;
2576
+ /**
2577
+ * Counts subscriptions that bind a specific PlanVersion — both
2578
+ * via the active `planVersionId` and via the scheduled `pendingPlanVersionId`.
2579
+ * Needed by the `PlanVersionsService` for the editability decision:
2580
+ * a published-but-future PlanVersion stays correctable only as long
2581
+ * as no booking references it.
2582
+ *
2583
+ * Optional for backwards-compat reasons — if an adapter does not
2584
+ * implement the method, the service defensively treats the version as
2585
+ * frozen (fail-closed). Implementation hint: count in a single
2586
+ * COUNT(*) over the subscription table with an OR over the two
2587
+ * FK columns — not in two separate queries, to avoid race conditions.
2588
+ */
2589
+ countByPlanVersionId?(planVersionId: string): Promise<number>;
2590
+ /**
2591
+ * Counts active (= not-canceled, or cancellation still in the future)
2592
+ * SubscriptionBundle entries that bind a specific BundleVersion.
2593
+ * Bundles are versioned and marketed independently (analogous to
2594
+ * plans); the `BundlesService` needs the count for the editability
2595
+ * decision of a published-but-future BundleVersion.
2596
+ *
2597
+ * Implementation since P11.7.3: direct COUNT on
2598
+ * `subscription_bundles WHERE bundleVersionId = ? AND
2599
+ * (canceledAt IS NULL OR canceledEffectiveAt > NOW())`. Apps without
2600
+ * a SubscriptionBundle schema (or without the platform migration) can
2601
+ * still return 0; the editability feature is then no longer
2602
+ * fail-closed against bookings, but still latest-in-chain +
2603
+ * validFrom-future.
2604
+ *
2605
+ * Optional — if not implemented, the service defensively treats the
2606
+ * version as frozen (fail-closed).
2607
+ */
2608
+ countByBundleVersionId?(bundleVersionId: string): Promise<number>;
2609
+ /**
2610
+ * Counts active subscriptions (status `ACTIVE` or `TRIAL`) per plan key,
2611
+ * platform-wide across all tenants of the project — feeds the tenant
2612
+ * column of the SuperAdmin plan list (`GET /admin/catalog/plans/tenant-counts`).
2613
+ * Cross-version: counts the plan, not a single PlanVersion
2614
+ * (subscriptions on superseded versions are included). `projectKey` is
2615
+ * informational for single-project consumers.
2616
+ *
2617
+ * Returns a map `planKey → count`; plans without an active subscription
2618
+ * are missing (UI defaults to 0). Platform-wide count across all tenants →
2619
+ * adapters must count RLS-exempt.
2620
+ *
2621
+ * Optional — if not implemented, the tenant column stays 0.
2622
+ */
2623
+ countActiveByPlanKey?(projectKey: string): Promise<Record<string, number>>;
2624
+ }
2625
+ /**
2626
+ * Adapter for the `subscription_bundles` junction.
2627
+ * Consumers implement it against their Prisma table. Writing
2628
+ * via `add` / `cancel` is always a side effect of the subscription-service
2629
+ * methods — the repository is dumb persistence, no domain
2630
+ * constraints (plan compatibility, minimum-term default) here.
2631
+ */
2632
+ interface SubscriptionBundleRepository {
2633
+ /** All bundle bookings of a subscription, newest first. */
2634
+ listBySubscription(subscriptionId: string): Promise<SubscriptionBundleRecord[]>;
2635
+ /** A single booking (for the cancel/detail flow). */
2636
+ findById(subscriptionBundleId: string): Promise<SubscriptionBundleRecord | null>;
2637
+ /**
2638
+ * Active bookings of a subscription (`canceledAt IS NULL OR
2639
+ * canceledEffectiveAt > NOW()`). Used by the Entitlement path.
2640
+ *
2641
+ * `tx` is set when the call happens inside `enforceLimit`'s interactive
2642
+ * transaction — adapters should then query on the transaction connection
2643
+ * instead of drawing an extra pool connection (starvation guard, #70).
2644
+ */
2645
+ listActiveBySubscription(subscriptionId: string, asOf?: Date, tx?: TransactionContext): Promise<SubscriptionBundleRecord[]>;
2646
+ add(data: CreateSubscriptionBundleData): Promise<SubscriptionBundleRecord>;
2647
+ /**
2648
+ * Sets `canceledAt` + `canceledEffectiveAt`. Throws on already
2649
+ * canceled bookings — the service may offer "undo cancellation"
2650
+ * as a separate path (not in this iteration).
2651
+ */
2652
+ cancel(subscriptionBundleId: string, data: CancelSubscriptionBundleData): Promise<SubscriptionBundleRecord>;
2653
+ /**
2654
+ * "Undo cancellation": resets `canceledAt` + `canceledEffectiveAt` to
2655
+ * NULL. Only meaningful as long as the cancellation is not yet effective
2656
+ * (`canceledEffectiveAt > NOW()`); the validity check is done by the service.
2657
+ */
2658
+ reactivate(subscriptionBundleId: string): Promise<SubscriptionBundleRecord>;
2659
+ /**
2660
+ * Counts active bundle bookings for a BundleVersion (same
2661
+ * semantics as `SubscriptionRepository.countByBundleVersionId`, only
2662
+ * directly on the junction adapter). Shared by both repository
2663
+ * implementations to avoid drift.
2664
+ */
2665
+ countActiveByBundleVersionId(bundleVersionId: string, asOf?: Date): Promise<number>;
2666
+ }
2667
+ /**
2668
+ * Append-only repository for V3 SubscriptionContracts. Contracts are the
2669
+ * contractually binding source for billing and entitlement; catalog FKs are only
2670
+ * trace data. Implementations may close existing contracts (at the domain level)
2671
+ * only via `terminate`, not overwrite LineItems/Snapshots.
2672
+ */
2673
+ interface SubscriptionContractRepository {
2674
+ list(filter: SubscriptionContractFilter): Promise<SubscriptionContractRecord[]>;
2675
+ findById(contractId: string): Promise<SubscriptionContractRecord | null>;
2676
+ /**
2677
+ * `tx` is set when the call happens inside `enforceLimit`'s interactive
2678
+ * transaction — adapters should then query on the transaction connection
2679
+ * instead of drawing an extra pool connection (starvation guard, #70).
2680
+ */
2681
+ findActiveByTenantId(tenantId: string, asOf?: Date, tx?: TransactionContext): Promise<SubscriptionContractRecord | null>;
2682
+ create(data: CreateSubscriptionContractData): Promise<SubscriptionContractRecord>;
2683
+ terminate(contractId: string, data: TerminateSubscriptionContractData): Promise<SubscriptionContractRecord>;
2684
+ }
2685
+ /**
2686
+ * Display form of a subscription for the tenant self-service UI.
2687
+ * Richer than `SubscriptionRecord` (which is only the aggregation form); contains
2688
+ * additional fields such as `billingCycle`, pilot/trial date and full
2689
+ * plan-version metadata.
2690
+ *
2691
+ * The platform controller `GET /billing/usage` maps this form 1:1 into the
2692
+ * response body. The consumer adapter loads from its own subscription
2693
+ * table (Prisma include planVersion + pendingPlanVersion).
2694
+ */
2695
+ interface SubscriptionUsageRecord {
2696
+ /**
2697
+ * Subscription primary key. Optional, because existing adapters may not
2698
+ * yet pass the column through — the platform service uses it
2699
+ * only for downstream steps such as atomic promo-redeem in the
2700
+ * onboarding endpoint. Adapters that want to support `POST /billing/onboarding/initial-subscription`
2701
+ * with a promo code must set `id`.
2702
+ */
2703
+ id?: string;
2704
+ plan: string;
2705
+ billingCycle: string;
2706
+ status: string;
2707
+ isPilot: boolean;
2708
+ pilotEndsAt: Date | null;
2709
+ trialEndsAt: Date | null;
2710
+ /** Subscription start (= period-window anchor for `periodEndAfter`). */
2711
+ startedAt: Date | null;
2712
+ /** Current period window — for proration and change-effective date. */
2713
+ currentPeriodStart: Date | null;
2714
+ currentPeriodEnd: Date | null;
2715
+ pendingPlan: string | null;
2716
+ pendingBillingCycle: string | null;
2717
+ pendingEffectiveAt: Date | null;
2718
+ planVersion: {
2719
+ id: string;
2720
+ planId: string;
2721
+ version: number;
2722
+ publishedAt: Date | null;
2723
+ supersededAt: Date | null;
2724
+ changeNote: string | null;
2725
+ };
2726
+ pendingPlanVersion: {
2727
+ id: string;
2728
+ planId: string;
2729
+ version: number;
2730
+ nonRegressive: boolean;
2731
+ changeNote: string | null;
2732
+ /** Catalog diff form from version-publish; free-form JSON structure. */
2733
+ publishedChanges: unknown;
2734
+ } | null;
2735
+ pendingPlanVersionEffectiveAt: Date | null;
2736
+ pendingPlanVersionAccepted: boolean;
2737
+ pendingPlanVersionAcceptedAt: Date | null;
2738
+ /**
2739
+ * P11.4: frozen package snapshot from the
2740
+ * `CheckoutOffer` that was activated during onboarding. Read-only —
2741
+ * serves only for display in the tenant self-service UI, so that the
2742
+ * tenant knows *which* advertised package was concretely booked.
2743
+ * `null` for subscriptions that did not originate from a CheckoutOffer
2744
+ * (direct creation, migration).
2745
+ */
2746
+ packageSnapshot?: unknown | null;
2747
+ /**
2748
+ * P11.4: reference to the original `CheckoutOffer.id`. Mostly not needed
2749
+ * for the UI (the snapshot is self-contained), but useful for support
2750
+ * tools and audit.
2751
+ */
2752
+ checkoutOfferId?: string | null;
2753
+ }
2754
+ /**
2755
+ * Read adapter for the UI/display form of a subscription. Used by
2756
+ * `TenantBillingController.getUsage`.
2757
+ */
2758
+ interface SubscriptionUsagePort {
2759
+ findForTenant(tenantId: string): Promise<SubscriptionUsageRecord | null>;
2760
+ }
2761
+ /**
2762
+ * Returns the current usage for all quotaKeys of a tenant declared via
2763
+ * `@DefinesQuota` (e.g. `{ users: 4, members: 850, storageGb: 1.2 }`).
2764
+ * The consumer may use its own counter strategies (Prisma counts,
2765
+ * DMS-service roundtrip, cached storage tracker, …) and must decide
2766
+ * soft-fail behavior itself.
2767
+ *
2768
+ * If a quotaKey is missing from the return object, the platform controller
2769
+ * maps it to `0` — robust display, even if a counter is not (yet) implemented.
2770
+ */
2771
+ interface UsageSnapshotPort {
2772
+ snapshot(tenantId: string): Promise<Record<string, number>>;
2773
+ }
2774
+ /** Input for `changePlanImmediate` with optional period-window reset. */
2775
+ interface ImmediatePlanChangeInput {
2776
+ planId: string;
2777
+ cycle: string;
2778
+ /** Reset the period window (pro-rata change). NULL for TRIAL. */
2779
+ periodStart: Date | null;
2780
+ periodEnd: Date | null;
2781
+ /** Target status — for TRIAL the status is not overwritten. */
2782
+ nextStatus: string | null;
2783
+ /**
2784
+ * Trial carry-over (#17): new trial end when changing DURING the trial.
2785
+ * Computed by the platform `changePlan` path from the `TrialProjectionPort`.
2786
+ * `undefined`/`null` → adapter leaves `trialEndsAt` unchanged (no trial
2787
+ * change, or target package without trial). A `Date` is persisted.
2788
+ */
2789
+ trialEndsAt?: Date | null;
2790
+ }
2791
+ /** Input for `schedulePlanChange` (change at period end). */
2792
+ interface ScheduledPlanChangeInput {
2793
+ pendingPlan: string;
2794
+ pendingBillingCycle: string;
2795
+ pendingEffectiveAt: Date;
2796
+ }
2797
+ /**
2798
+ * Input for `applyOnboardingSelection`. Plan-change fields that the
2799
+ * adapter persists atomically in a single transaction.
2800
+ */
2801
+ interface ApplyOnboardingSelectionInput {
2802
+ planId: string;
2803
+ cycle: string;
2804
+ /** For TRIAL → null, otherwise period start from `initialPeriodWindow`. */
2805
+ periodStart: Date | null;
2806
+ periodEnd: Date | null;
2807
+ /** For TRIAL → null, otherwise typically `'ACTIVE'`. */
2808
+ nextStatus: string | null;
2809
+ }
2810
+ /**
2811
+ * Result of the atomically executed onboarding step. Contains all
2812
+ * effects that the platform service can log / respond with downstream.
2813
+ */
2814
+ interface ApplyOnboardingSelectionResult {
2815
+ plan: string;
2816
+ billingCycle: string;
2817
+ subscriptionId: string;
2818
+ /** null if no redeemPromo callback was provided or the callback returned null. */
2819
+ promoRedemption: PromoCodeRedemptionRecord | null;
2820
+ }
2821
+ /**
2822
+ * Callback signature for promo-code redemption WITHIN the onboarding
2823
+ * transaction. The platform service injects a closure that calls `PromoCodesService.
2824
+ * redeemInTransaction(...)`; the adapter calls it after the
2825
+ * subscription update, so that everything lives in a single DB transaction.
2826
+ */
2827
+ type RedeemPromoInTransactionCallback = (tx: TransactionContext, subscriptionId: string) => Promise<PromoCodeRedemptionRecord>;
2828
+ /**
2829
+ * Write adapter for tenant self-service mutations
2830
+ * (`POST /billing/plan`, `/billing/cancel` etc.).
2831
+ *
2832
+ * The consumer implementation persists into its subscription table.
2833
+ * Atomicity lies in the adapter, because transaction-client types are
2834
+ * app-specific. The platform service calls `invalidateTenant` in the
2835
+ * EntitlementService after a successful adapter call.
2836
+ */
2837
+ interface TenantSubscriptionWritePort {
2838
+ /** Immediate change: set plan + cycle, clear pending fields, optionally reset the period. */
2839
+ changePlanImmediate(tenantId: string, input: ImmediatePlanChangeInput): Promise<{
2840
+ plan: string;
2841
+ billingCycle: string;
2842
+ }>;
2843
+ /** Change at period end: set pending fields. */
2844
+ schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<void>;
2845
+ /**
2846
+ * Marks the pending PlanVersion as accepted. Idempotent — a duplicate
2847
+ * accept is a no-op. Returns `alreadyAccepted: true` if the status was
2848
+ * already set.
2849
+ */
2850
+ acceptPendingPlanVersion(tenantId: string, userId: string, now: Date): Promise<{
2851
+ accepted: boolean;
2852
+ acceptedAt: Date | null;
2853
+ effectiveAt: Date | null;
2854
+ alreadyAccepted: boolean;
2855
+ }>;
2856
+ /**
2857
+ * Cancel the subscription. `immediate=true` → status CANCELED from now;
2858
+ * `false` → canceledAt = currentPeriodEnd, status is preserved.
2859
+ */
2860
+ cancelSubscription(tenantId: string, immediate: boolean, now: Date): Promise<{
2861
+ canceledAt: Date | null;
2862
+ status: string;
2863
+ }>;
2864
+ /**
2865
+ * Atomic onboarding creation: sets plan + cycle + period window
2866
+ * AND optionally calls a promo-redeem callback — all in a
2867
+ * single consumer transaction. Without this method the
2868
+ * platform service falls back to sequential `changePlanImmediate +
2869
+ * promoCodes.redeem` calls (best-effort,
2870
+ * P10.1.1 transitional solution).
2871
+ *
2872
+ * Optional, because existing adapters can add the support
2873
+ * incrementally — a missing implementation is not a hard error.
2874
+ */
2875
+ applyOnboardingSelection?(tenantId: string, input: ApplyOnboardingSelectionInput, redeemPromo: RedeemPromoInTransactionCallback | null): Promise<ApplyOnboardingSelectionResult>;
2876
+ }
2877
+ /** Read adapter for PlanVersions. */
2878
+ interface PlanVersionRepository {
2879
+ /**
2880
+ * Currently published (= live) PlanVersion of a plan:
2881
+ * `publishedAt IS NOT NULL AND supersededAt IS NULL`. Optionally within a
2882
+ * transaction.
2883
+ *
2884
+ * Note: ignores `validFrom`/`validUntil`. For time-aware
2885
+ * resolution (onboarding, plan fallback for TRIAL) use `findActive`.
2886
+ */
2887
+ findLatestLive(planId: string, tx?: TransactionContext): Promise<PlanVersionRecord | null>;
2888
+ /**
2889
+ * PlanVersion of a plan active at `asOf`:
2890
+ * `publishedAt IS NOT NULL`
2891
+ * `(validFrom IS NULL OR validFrom <= asOf)`
2892
+ * `(validUntil IS NULL OR validUntil >= startOfUtcDay(asOf))`
2893
+ *
2894
+ * `validUntil` is day-inclusive. If multiple versions match, adapters
2895
+ * return the highest `validFrom`, explicitly ordering null start dates
2896
+ * last as a legacy fallback. Adapters without validity columns may omit
2897
+ * the method (consumers fall back to `findLatestLive`).
2898
+ */
2899
+ findActive?(planId: string, asOf?: Date, tx?: TransactionContext): Promise<PlanVersionRecord | null>;
2900
+ }
2901
+
2902
+ /** Aggregate snapshot of subscriptions, orchestrated by `AdminStatsService`. */
2903
+ interface SubscriptionStatsSnapshot {
2904
+ /** Sum of all non-soft-deleted subscriptions. */
2905
+ total: number;
2906
+ /** Sum of subscriptions with `isPilot: true`. */
2907
+ pilots: number;
2908
+ /** Sum of subscriptions with `status: 'TRIAL'`. */
2909
+ trialing: number;
2910
+ /** Map planId → count. */
2911
+ byPlan: Record<string, number>;
2912
+ /** Map status → count (ACTIVE / TRIAL / PAST_DUE / CANCELED / PENDING_SALES). */
2913
+ byStatus: Record<string, number>;
2914
+ }
2915
+ /**
2916
+ * Stats adapter for subscriptions. Consumers implement this based on their
2917
+ * Prisma `subscription.groupBy(...)` / `count(...)` calls.
2918
+ */
2919
+ interface SubscriptionStatsPort {
2920
+ getStats(): Promise<SubscriptionStatsSnapshot>;
2921
+ }
2922
+ /** Top promo-code entry (highest redemption count). */
2923
+ interface TopPromoCode {
2924
+ code: string;
2925
+ redemptionsCount: number;
2926
+ status: string;
2927
+ }
2928
+ /** Aggregate snapshot of promo codes. */
2929
+ interface PromoCodeStatsSnapshot {
2930
+ /** Sum of all non-soft-deleted promo codes. */
2931
+ total: number;
2932
+ /** Map status → count (ACTIVE / PAUSED / EXPIRED / EXHAUSTED). */
2933
+ byStatus: Record<string, number>;
2934
+ /** Highest-redemption code; null when no codes exist. */
2935
+ top: TopPromoCode | null;
2936
+ }
2937
+ /** Stats adapter for promo codes. */
2938
+ interface PromoCodeStatsPort {
2939
+ getStats(): Promise<PromoCodeStatsSnapshot>;
2940
+ }
2941
+ /** Aggregate snapshot of the audit log. */
2942
+ interface AuditStatsSnapshot {
2943
+ /** Number of entries in the last N days. */
2944
+ countLastNDays: number;
2945
+ /** Default 7. Configurable via `forRoot.auditWindowDays`. */
2946
+ nDays: number;
2947
+ }
2948
+ /** Stats adapter for the audit log. */
2949
+ interface AuditStatsPort {
2950
+ /** Number of audit events since `since`. */
2951
+ countSince(since: Date): Promise<number>;
2952
+ }
2953
+ /**
2954
+ * Identifier of an admin actor for the audit log. `source` distinguishes
2955
+ * web UI (`'web'`) and CLI (`'cli'`); `context` is the session ID (web)
2956
+ * or the hostname (CLI).
2957
+ */
2958
+ interface AdminActor {
2959
+ userId: string;
2960
+ email: string;
2961
+ source: 'web' | 'cli';
2962
+ context: string;
2963
+ }
2964
+ /**
2965
+ * Audit adapter: platform services write to the audit log through this
2966
+ * interface. The consumer implementation persists the records (e.g.
2967
+ * Prisma `auditLog.create`, Django `AuditLog.objects.create`).
2968
+ *
2969
+ * `action` is SCREAMING_SNAKE_CASE (see `audit-event.schema.json`).
2970
+ * `changes` is a free-form object; platform services attach the `actor` tag
2971
+ * automatically.
2972
+ */
2973
+ interface AuditPort {
2974
+ write(input: {
2975
+ actor: AdminActor;
2976
+ entity: string;
2977
+ entityId: string;
2978
+ action: string;
2979
+ changes?: Record<string, unknown>;
2980
+ }): Promise<void>;
2981
+ }
2982
+ /**
2983
+ * Read/query adapter for audit logs. Used by the CLI `<app> audit tail` and
2984
+ * UI audit pages. The consumer implementation translates the filter object
2985
+ * (see `audit-event.types.ts.AuditQuery`) into the respective DB query. The
2986
+ * returned records follow `AuditEntry` from the same file.
2987
+ */
2988
+ interface AuditQueryPort {
2989
+ list(filter: AuditQuery): Promise<AuditEntry[]>;
2990
+ }
2991
+ interface AdminTenantListFilter {
2992
+ status?: string;
2993
+ plan?: string;
2994
+ search?: string;
2995
+ }
2996
+ interface AdminUserListFilter {
2997
+ q?: string;
2998
+ tenant?: string;
2999
+ }
3000
+ interface AdminAuditListFilter {
3001
+ actor?: string;
3002
+ action?: string;
3003
+ entity?: string;
3004
+ since?: string;
3005
+ limit?: number;
3006
+ }
3007
+ interface AdminTenantListRow {
3008
+ id: string;
3009
+ slug: string;
3010
+ name: string;
3011
+ isActive: boolean;
3012
+ deletedAt: string | null;
3013
+ plan: string | null;
3014
+ status: string | null;
3015
+ createdAt: string;
3016
+ /** App-selected counters, exposed as ordinary row fields for the UI. */
3017
+ [metric: string]: string | number | boolean | null;
3018
+ }
3019
+ interface AdminTenantDetail {
3020
+ id: string;
3021
+ slug: string;
3022
+ name: string;
3023
+ isActive: boolean;
3024
+ subscription: {
3025
+ plan: string;
3026
+ status: string;
3027
+ billingCycle: string;
3028
+ isPilot: boolean;
3029
+ trialEndsAt: string | null;
3030
+ pilotEndsAt: string | null;
3031
+ } | null;
3032
+ users: Array<{
3033
+ id: string;
3034
+ email: string;
3035
+ firstName?: string;
3036
+ lastName?: string;
3037
+ createdAt: string;
3038
+ }>;
3039
+ counts: Record<string, number>;
3040
+ }
3041
+ interface AdminUserListRow {
3042
+ id: string;
3043
+ email: string;
3044
+ firstName: string;
3045
+ lastName: string;
3046
+ role: string;
3047
+ isActive: boolean;
3048
+ tenantSlug: string | null;
3049
+ lastLoginAt: string | null;
3050
+ createdAt: string;
3051
+ /** Apps may add columns without replacing the standard page contract. */
3052
+ [extra: string]: unknown;
3053
+ }
3054
+ interface AdminSubscriptionListRow {
3055
+ id: string;
3056
+ tenant: {
3057
+ slug: string;
3058
+ name: string;
3059
+ };
3060
+ plan: string;
3061
+ status: string;
3062
+ billingCycle: string;
3063
+ periodEndsAt: string | null;
3064
+ monthlyNet: string | null;
3065
+ /** Apps may add columns without replacing the standard page contract. */
3066
+ [extra: string]: unknown;
3067
+ }
3068
+ interface AdminTenantStateResult {
3069
+ ok: true;
3070
+ id: string;
3071
+ slug: string;
3072
+ isActive: boolean;
3073
+ status: string | null;
3074
+ }
3075
+ /**
3076
+ * One narrow backend boundary for the generic Tenant/User/Audit/Subscription
3077
+ * SuperAdmin pages. The canonical Prisma adapter implements it out of the box;
3078
+ * custom schemas replace only this port, while controllers and DTOs stay in
3079
+ * SaaSiCat.
3080
+ */
3081
+ interface AdminResourcesPort {
3082
+ listTenants(filter: AdminTenantListFilter): Promise<AdminTenantListRow[]>;
3083
+ getTenantDetail(slug: string): Promise<AdminTenantDetail | null>;
3084
+ setTenantActive(slug: string, active: boolean, subscriptionStatus: string): Promise<AdminTenantStateResult | null>;
3085
+ listUsers(filter: AdminUserListFilter): Promise<AdminUserListRow[]>;
3086
+ listAudit(filter: AdminAuditListFilter): Promise<AuditEntry[]>;
3087
+ listSubscriptions(): Promise<AdminSubscriptionListRow[]>;
3088
+ }
3089
+ /**
3090
+ * Read adapter for the current AdminManifest. The consumer implementation
3091
+ * delegates to its `AdminManifestService.getManifest()`. The platform CLI
3092
+ * uses this for `<app> manifest dump|hash|check` etc.
3093
+ */
3094
+ interface ManifestAccessPort {
3095
+ getManifest(): AdminManifest;
3096
+ /** Optional: forces a rebuild from the contributions (e.g. after code reload). */
3097
+ rebuild?(): AdminManifest;
3098
+ }
3099
+ /**
3100
+ * Adapter for the RLS bypass context. Platform code calls `runWithBypass`,
3101
+ * the consumer implementation triggers the Postgres session variable
3102
+ * (`set_config('app.bypass_rls', 'true', true)`) or the equivalent in
3103
+ * Django/other stacks. The execution context lives for exactly one
3104
+ * request pipeline (AsyncLocalStorage / `contextvars` etc.).
3105
+ *
3106
+ * SuperAdmin operations are platform-wide without tenant scope — without
3107
+ * bypass, all RLS-protected reads would come back empty.
3108
+ */
3109
+ interface RlsBypassPort {
3110
+ runWithBypass<T>(fn: () => Promise<T>): Promise<T>;
3111
+ }
3112
+
3113
+ /** Filter for `PlanRepository.list()`. */
3114
+ interface PlanListFilter {
3115
+ projectKey: string;
3116
+ /** Exclude soft-deleted plans — default `true`. */
3117
+ excludeDeleted?: boolean;
3118
+ /**
3119
+ * Only plans with at least one live version
3120
+ * (`publishedAt` set, `supersededAt` null). Default `false` — plan
3121
+ * management still lists drafts as well. Selection masks (e.g.
3122
+ * pilot setup) set `true` so that no unbookable plans appear.
3123
+ */
3124
+ onlyPublished?: boolean;
3125
+ }
3126
+ /**
3127
+ * Adapter for `Plan` stem + `PlanVersion` lifecycle persistence
3128
+ *. Consumers implement this against the Prisma
3129
+ * tables `plans` + `plan_versions`.
3130
+ *
3131
+ * Pack 1 (CRUD stem) and Pack 2a (lifecycle) live in one interface,
3132
+ * analogous to `BundleRepository` — but the lifecycle methods are **optional**
3133
+ * (apps without a SuperAdmin editor do not implement them; CatalogModule
3134
+ * then does not register `PlanVersionsService`).
3135
+ *
3136
+ * Binding for lifecycle:
3137
+ * - `createDraft` may only succeed if no other draft version
3138
+ * exists for the same `planId` (partial unique index in the SQL
3139
+ * migration).
3140
+ * - `publishDraft` sets `publishedAt = NOW()`, `publishedChanges`,
3141
+ * `nonRegressive`, `publishedByUserId` on the draft — and supersedes the
3142
+ * previously live version (same `planId`, `publishedAt IS NOT NULL`,
3143
+ * `supersededAt IS NULL`) to `supersededAt = NOW()`. Both in one
3144
+ * transaction.
3145
+ */
3146
+ interface PlanRepository {
3147
+ list(filter: PlanListFilter): Promise<PlanRow[]>;
3148
+ findById(planId: string): Promise<PlanRow | null>;
3149
+ findByKey(projectKey: string, planKey: string): Promise<PlanRow | null>;
3150
+ create(data: CreatePlanData): Promise<PlanRow>;
3151
+ update(planId: string, data: UpdatePlanData): Promise<PlanRow>;
3152
+ /** Sets `deletedAt` to NOW(); soft-deleted plans are filtered from `list` by default. */
3153
+ softDelete(planId: string): Promise<void>;
3154
+ /**
3155
+ * Hard-removes the plan stem from the DB (no `deletedAt`, no
3156
+ * recovery path). The service only calls this method after it has
3157
+ * verified that no `PlanVersion` still exists — consumers
3158
+ * may rely on the table being empty. If the
3159
+ * method is not implemented, the service responds with 422
3160
+ * `PLAN_HARD_DELETE_NOT_IMPLEMENTED`.
3161
+ */
3162
+ hardDelete?(planId: string): Promise<void>;
3163
+ /**
3164
+ * Returns all versions of a plan stem (drafts + published +
3165
+ * superseded), sorted by `version` ascending.
3166
+ */
3167
+ listVersions?(planKey: string): Promise<PlanVersionRow[]>;
3168
+ findVersionById?(versionId: string): Promise<PlanVersionRow | null>;
3169
+ findCurrentDraft?(planKey: string): Promise<PlanVersionRow | null>;
3170
+ /**
3171
+ * Currently published (= live) PlanVersion of a plan:
3172
+ * `publishedAt IS NOT NULL AND supersededAt IS NULL`.
3173
+ *
3174
+ * Note: returns the *newest* published version by
3175
+ * `version` number and ignores `validFrom`/`validUntil`. For
3176
+ * time-aware reads (onboarding, marketing catalog, entitlement
3177
+ * fallback) use `findActivePlanVersion(planKey, asOf)`, which returns
3178
+ * the version *active at a point in time*.
3179
+ */
3180
+ findLatestLivePlanVersion?(planKey: string, tx?: TransactionContext): Promise<PlanVersionRow | null>;
3181
+ /**
3182
+ * PlanVersion of a plan active at `asOf` ( extended):
3183
+ * `publishedAt IS NOT NULL`
3184
+ * `(validFrom IS NULL OR validFrom <= asOf)`
3185
+ * `(validUntil IS NULL OR validUntil >= startOfUtcDay(asOf))` — day-inclusive
3186
+ *
3187
+ * `validFrom IS NULL` is treated like "valid since forever" so that legacy data
3188
+ * without a start date (published before the §4.2 publish requirement) does not
3189
+ * fall out of the catalog. `validUntil` is day-inclusive (calendar day): the version
3190
+ * is valid until the end of its validUntil day, not just until midnight.
3191
+ * Adapters build the WHERE via `buildActivePlanVersionWhere`.
3192
+ *
3193
+ * If multiple match: the one with the highest `validFrom` (= the
3194
+ * "last active"). Adapters must request `NULLS LAST` explicitly so a
3195
+ * null start date remains a genuine fallback. Default `asOf` is the call
3196
+ * time.
3197
+ *
3198
+ * Usage: everything that concerns *new* bookings/plan changes
3199
+ * (onboarding, public marketing, entitlement fallback on TRIAL).
3200
+ * Existing subscriptions stay on their bound `planVersionId`
3201
+ * (P1 contract protection).
3202
+ */
3203
+ findActivePlanVersion?(planKey: string, asOf?: Date, tx?: TransactionContext): Promise<PlanVersionRow | null>;
3204
+ /**
3205
+ * Creates a new draft version (`publishedAt = null`). Throws if
3206
+ * a draft already exists (partial unique index violation).
3207
+ * Computes `version` as `MAX(version) + 1` over all versions of the
3208
+ * `planId`.
3209
+ */
3210
+ createPlanVersionDraft?(data: CreatePlanVersionDraftData): Promise<PlanVersionRow>;
3211
+ updatePlanVersionDraft?(versionId: string, data: UpdatePlanVersionDraftData): Promise<PlanVersionRow>;
3212
+ /**
3213
+ * Publishes a draft version atomically:
3214
+ * 1. Sets `publishedAt = NOW()`, `publishedChanges`, `nonRegressive`,
3215
+ * `publishedByUserId`, `validFrom` on the draft.
3216
+ * 2. Sets `supersededAt = NOW()` on the previously live version AND
3217
+ * `validUntil = validFrom - 1 day` (auto-succession).
3218
+ * 3. Sets `validUntil` on the new version (optional, default null = unbounded).
3219
+ */
3220
+ publishPlanVersionDraft?(versionId: string, publishMeta: {
3221
+ publishedByUserId: string | null;
3222
+ publishedChanges: VersionChange[];
3223
+ nonRegressive: boolean;
3224
+ /** Required — validated by the service before the repository call. */
3225
+ validFrom: Date;
3226
+ validUntil: Date | null;
3227
+ }, tx?: TransactionContext): Promise<PlanVersionRow>;
3228
+ /**
3229
+ * Hard-discards a draft version (`publishedAt === null`) from the DB.
3230
+ * Throws if the version was already published — published versions
3231
+ * remain immutable (audit + existing subscriptions
3232
+ * reference them). If the version does not exist, discard is
3233
+ * a no-op (the caller may have already used a different path in parallel).
3234
+ */
3235
+ deletePlanVersionDraft?(versionId: string): Promise<void>;
3236
+ /**
3237
+ * Sets the `endsAt` date chosen by the SuperAdmin on a **published**
3238
+ * PlanVersion (`publishedAt != null && supersededAt == null`). Idempotent —
3239
+ * a second call with a different date overwrites the field.
3240
+ *
3241
+ * Service-side preconditions (live + future date) are checked by the
3242
+ * `PlanVersionsService`; the adapter only persists. If the
3243
+ * method is not implemented, the service responds with 422
3244
+ * `PLAN_TERMINATE_NOT_IMPLEMENTED`.
3245
+ */
3246
+ terminate?(versionId: string, endsAt: Date): Promise<PlanVersionRow>;
3247
+ }
3248
+ /** Filter for `BundleRepository.list()`. */
3249
+ interface BundleListFilter {
3250
+ projectKey: string;
3251
+ /** Exclude soft-deleted bundles — default `true`. */
3252
+ excludeDeleted?: boolean;
3253
+ }
3254
+ /**
3255
+ * Adapter for `Bundle` + `BundleVersion` persistence. Consumers implement
3256
+ * this against their Prisma tables (`bundles` + `bundle_versions`).
3257
+ *
3258
+ * Binding:
3259
+ * - `createDraft` may only succeed if no other draft version
3260
+ * exists for the same `bundleId` (partial unique index in the SQL
3261
+ * migration; see the README in the Prisma fragment).
3262
+ * - `publishDraft` sets `publishedAt = NOW()`, `publishedChanges`,
3263
+ * `nonRegressive`, `publishedByUserId` — and supersedes the previously live
3264
+ * version (same `bundleId`, `publishedAt IS NOT NULL`,
3265
+ * `supersededAt IS NULL`) to `supersededAt = NOW()`.
3266
+ * - Operations that need atomicity may optionally accept a
3267
+ * `TransactionContext`.
3268
+ */
3269
+ interface BundleRepository {
3270
+ list(filter: BundleListFilter): Promise<BundleRow[]>;
3271
+ findById(bundleId: string): Promise<BundleRow | null>;
3272
+ findByKey(projectKey: string, bundleKey: string): Promise<BundleRow | null>;
3273
+ create(data: CreateBundleData): Promise<BundleRow>;
3274
+ update(bundleId: string, data: UpdateBundleData): Promise<BundleRow>;
3275
+ /** Sets `deletedAt` to NOW(); soft-deleted bundles are filtered from `list` by default. */
3276
+ softDelete(bundleId: string): Promise<void>;
3277
+ listVersions(bundleId: string): Promise<BundleVersionRow[]>;
3278
+ /**
3279
+ * `tx` is set when the entitlement path resolves bundle versions inside
3280
+ * `enforceLimit`'s interactive transaction (starvation guard, #70).
3281
+ */
3282
+ findVersionById(versionId: string, tx?: TransactionContext): Promise<BundleVersionRow | null>;
3283
+ findCurrentDraft(bundleId: string): Promise<BundleVersionRow | null>;
3284
+ /**
3285
+ * Currently published (= live) BundleVersion of a bundle:
3286
+ * `publishedAt IS NOT NULL AND supersededAt IS NULL`.
3287
+ *
3288
+ * This deliberately ignores `validFrom`/`validUntil`. For new bookings
3289
+ * and other time-aware catalog reads use `findActiveBundleVersion`.
3290
+ */
3291
+ findLatestLive(bundleId: string, tx?: TransactionContext): Promise<BundleVersionRow | null>;
3292
+ /**
3293
+ * BundleVersion active at `asOf`:
3294
+ * `publishedAt IS NOT NULL`
3295
+ * `(validFrom IS NULL OR validFrom <= asOf)`
3296
+ * `(validUntil IS NULL OR validUntil >= startOfUtcDay(asOf))`
3297
+ *
3298
+ * Both boundaries are inclusive. If multiple versions match, adapters
3299
+ * return the highest `validFrom`, then the highest `version`; a null
3300
+ * `validFrom` is a legacy fallback. Default `asOf` is the call time.
3301
+ *
3302
+ * Optional so adapters backed by legacy schemas without validity columns
3303
+ * can omit the capability and consumers can fall back explicitly.
3304
+ */
3305
+ findActiveBundleVersion?(bundleId: string, asOf?: Date, tx?: TransactionContext): Promise<BundleVersionRow | null>;
3306
+ /**
3307
+ * Creates a new draft version (`publishedAt = null`). Throws if
3308
+ * a draft already exists (partial unique index violation).
3309
+ * Computes `version` as `MAX(version) + 1` over all versions of the
3310
+ * `bundleId`.
3311
+ */
3312
+ createDraft(data: CreateBundleVersionDraftData): Promise<BundleVersionRow>;
3313
+ updateDraft(versionId: string, data: UpdateBundleVersionDraftData): Promise<BundleVersionRow>;
3314
+ /**
3315
+ * Publishes a draft version atomically (
3316
+ * Pack 2c, analogous to `PlanRepository.publishPlanVersionDraft`):
3317
+ * 1. Sets `publishedAt = NOW()`, `publishedChanges`, `nonRegressive`,
3318
+ * `publishedByUserId`, `validFrom` on the draft.
3319
+ * 2. Sets `supersededAt = NOW()` on the previously live version (if
3320
+ * present) AND its `validUntil = validFrom - 1 day`
3321
+ * (auto-succession).
3322
+ * 3. Sets `validUntil` on the new version (optional, default null
3323
+ * = unbounded).
3324
+ *
3325
+ * All steps run in one transaction (consumers usually pass
3326
+ * through a `TransactionRunner`). Pre-checked on the service side:
3327
+ * `validFrom > previous.validFrom`, gapless constraint
3328
+ * if the predecessor carries a `validUntil` — the adapter only
3329
+ * persists, it does not validate again.
3330
+ */
3331
+ publishDraft(versionId: string, publishMeta: {
3332
+ publishedByUserId: string | null;
3333
+ publishedChanges: VersionChange[];
3334
+ nonRegressive: boolean;
3335
+ /** Required — validated by the service before the repository call. */
3336
+ validFrom: Date;
3337
+ validUntil: Date | null;
3338
+ }, tx?: TransactionContext): Promise<BundleVersionRow>;
3339
+ /**
3340
+ * Hard-discards a draft version (`publishedAt === null`) from the DB.
3341
+ * Throws if the version was already published — published versions
3342
+ * remain immutable (contract protection P1). If the version
3343
+ * does not exist, discard is a no-op (the caller may have already used
3344
+ * a different path in parallel).
3345
+ *
3346
+ * Optional for backwards-compat reasons — if the adapter does not
3347
+ * implement it, the service responds with 422
3348
+ * `BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED`.
3349
+ */
3350
+ deleteDraft?(versionId: string): Promise<void>;
3351
+ }
3352
+ /**
3353
+ * Adapter for `marketing_projections`. **No versioning** — per
3354
+ * (`targetType`, `targetVersionId`, `locale`) there is exactly one row
3355
+ * that is edited directly. Marketing edits go live immediately, because they
3356
+ * only control the public catalog display, not existing subscriptions.
3357
+ *
3358
+ * Uniqueness over (`targetType`, `targetVersionId`, `locale`) is
3359
+ * enforced as a unique index in the DB schema — `create` with a conflict throws.
3360
+ */
3361
+ interface MarketingProjectionRepository {
3362
+ list(filter: MarketingProjectionFilter): Promise<MarketingProjectionRow[]>;
3363
+ findById(id: string): Promise<MarketingProjectionRow | null>;
3364
+ /**
3365
+ * Finds a projection by the triple
3366
+ * (`targetType`, `targetVersionId`, `locale`).
3367
+ */
3368
+ findByTarget(targetType: string, targetVersionId: string, locale: string): Promise<MarketingProjectionRow | null>;
3369
+ create(data: CreateMarketingProjectionData): Promise<MarketingProjectionRow>;
3370
+ update(id: string, data: UpdateMarketingProjectionData): Promise<MarketingProjectionRow>;
3371
+ /** Hard delete — no soft-delete column (not versioned). */
3372
+ delete(id: string): Promise<void>;
3373
+ }
3374
+ /** Upsert input for a capability from the discovery sync. */
3375
+ interface UpsertCapabilityEntryData {
3376
+ projectKey: string;
3377
+ capabilityKey: string;
3378
+ label: string;
3379
+ description: string | null;
3380
+ featureKey: string | null;
3381
+ bundleKey: string | null;
3382
+ /** Read-only code fact from the snapshot (#20) — the sync always overwrites. */
3383
+ codeStatus: CapabilityCodeStatus;
3384
+ owner: string | null;
3385
+ kind: CapabilityCatalogEntryRow['kind'];
3386
+ replacementKey: string | null;
3387
+ deprecatedAt: string | null;
3388
+ removalPlannedAt: string | null;
3389
+ reason: string | null;
3390
+ }
3391
+ /** Upsert input for a feature from the discovery sync. */
3392
+ interface UpsertFeatureEntryData {
3393
+ projectKey: string;
3394
+ featureKey: string;
3395
+ label: string;
3396
+ description: string | null;
3397
+ discoveryStatus: DiscoveryStatus;
3398
+ /** Code-discovered feature dependencies (#35) — the sync always overwrites. */
3399
+ requires: string[];
3400
+ /** Old feature keys that this feature replaces (#39) — the sync always overwrites. */
3401
+ replaces: string[];
3402
+ /** true = base/always included (not bookable per plan). Deterministic from the registry. */
3403
+ core?: boolean;
3404
+ }
3405
+ /** Upsert input for a quota from the discovery sync. */
3406
+ interface UpsertQuotaEntryData {
3407
+ projectKey: string;
3408
+ quotaKey: string;
3409
+ label: string;
3410
+ description: string | null;
3411
+ unit: string;
3412
+ featureKey: string | null;
3413
+ usageProvider: string | null;
3414
+ enforcementMode: QuotaCatalogEntryRow['enforcementMode'];
3415
+ discoveryStatus: DiscoveryStatus;
3416
+ /** Old quotaKeys that this quota replaces (#39) — the sync always overwrites. */
3417
+ replaces: string[];
3418
+ }
3419
+ /**
3420
+ * Review update of a feature/quota (`setFeatureReview`/
3421
+ * `setQuotaReview`). The service has already validated the transition and
3422
+ * resolved the approval fields — the adapter persists all four fields 1:1
3423
+ * (`null` clears).
3424
+ */
3425
+ interface SetCatalogEntryReviewData {
3426
+ discoveryStatus: DiscoveryStatus;
3427
+ approvedAt: string | null;
3428
+ approvedBy: string | null;
3429
+ approvedSignature: string | null;
3430
+ }
3431
+ /**
3432
+ * Adapter for `capability_catalog_entries`, `feature_catalog_entries` and
3433
+ * `quota_catalog_entries`. Consumers implement this against their
3434
+ * Prisma tables.
3435
+ *
3436
+ * Binding:
3437
+ * - `upsert*` matches on (`projectKey`, `<key>`) and leaves `i18n`,
3438
+ * `sortOrder`, `createdAt` as well as the approval fields (`approvedAt`/
3439
+ * `approvedBy`/`approvedSignature`) **untouched** on an update —
3440
+ * only the code-derived fields + the status (resolved by the service)
3441
+ * are written.
3442
+ * - `retireMissing` marks all non-soft-deleted entries whose key
3443
+ * is not in `presentKeys`: capabilities → `codeStatus = 'retired'`,
3444
+ * features/quotas → `discoveryStatus = 'obsolete'`. Returns the count.
3445
+ */
3446
+ interface CatalogEntryRepository {
3447
+ listCapabilities(filter: CatalogEntryFilter): Promise<CapabilityCatalogEntryRow[]>;
3448
+ listFeatures(filter: CatalogEntryFilter): Promise<FeatureCatalogEntryRow[]>;
3449
+ listQuotas(filter: CatalogEntryFilter): Promise<QuotaCatalogEntryRow[]>;
3450
+ upsertCapability(data: UpsertCapabilityEntryData): Promise<CapabilityCatalogEntryRow>;
3451
+ upsertFeature(data: UpsertFeatureEntryData): Promise<FeatureCatalogEntryRow>;
3452
+ upsertQuota(data: UpsertQuotaEntryData): Promise<QuotaCatalogEntryRow>;
3453
+ retireMissing(projectKey: string, type: 'capability' | 'feature' | 'quota', presentKeys: string[]): Promise<number>;
3454
+ /**
3455
+ * Sets or clears the successor pointer of a feature/quota
3456
+ * (#39). The sync calls this when a key disappears from the snapshot
3457
+ * and another key claims it via `replaces` (`successorKey`
3458
+ * set), or when the key reappears in the code (`null`). Optional —
3459
+ * adapters without a `successor_key` column omit the methods, and the sync
3460
+ * then skips the pointers with a warn log.
3461
+ */
3462
+ setFeatureSuccessor?(projectKey: string, featureKey: string, successorKey: string | null): Promise<FeatureCatalogEntryRow>;
3463
+ setQuotaSuccessor?(projectKey: string, quotaKey: string, successorKey: string | null): Promise<QuotaCatalogEntryRow>;
3464
+ findFeature(projectKey: string, featureKey: string): Promise<FeatureCatalogEntryRow | null>;
3465
+ findQuota(projectKey: string, quotaKey: string): Promise<QuotaCatalogEntryRow | null>;
3466
+ setFeatureReview(projectKey: string, featureKey: string, data: SetCatalogEntryReviewData): Promise<FeatureCatalogEntryRow>;
3467
+ setQuotaReview(projectKey: string, quotaKey: string, data: SetCatalogEntryReviewData): Promise<QuotaCatalogEntryRow>;
3468
+ setFeatureI18n(projectKey: string, featureKey: string, i18n: CatalogEntryI18n): Promise<FeatureCatalogEntryRow>;
3469
+ setQuotaI18n(projectKey: string, quotaKey: string, i18n: CatalogEntryI18n): Promise<QuotaCatalogEntryRow>;
3470
+ /** Sets the editable base fields (default locale `label`/`description`). */
3471
+ setFeatureBase(projectKey: string, featureKey: string, data: UpdateCatalogEntryBaseData): Promise<FeatureCatalogEntryRow>;
3472
+ setQuotaBase(projectKey: string, quotaKey: string, data: UpdateCatalogEntryBaseData): Promise<QuotaCatalogEntryRow>;
3473
+ }
3474
+ /**
3475
+ * Adapter for `promotions`. **No versioning** — promotions are edited
3476
+ * directly (analogous to MarketingProjectionRepository). Consumers implement
3477
+ * this against their `promotions` Prisma table.
3478
+ */
3479
+ interface PromotionRepository {
3480
+ list(filter: PromotionFilter): Promise<PromotionRow[]>;
3481
+ findById(id: string): Promise<PromotionRow | null>;
3482
+ create(data: CreatePromotionData): Promise<PromotionRow>;
3483
+ update(id: string, data: UpdatePromotionData): Promise<PromotionRow>;
3484
+ /** Hard delete — no soft-delete column (not versioned). */
3485
+ delete(id: string): Promise<void>;
3486
+ }
3487
+ /**
3488
+ * Adapter for `marketing_settings` — one row per project. `get` returns
3489
+ * `null` as long as the SuperAdmin has saved nothing (then the full
3490
+ * `availableLocales` pool counts as active). `upsert` creates the row or replaces it.
3491
+ */
3492
+ interface MarketingSettingsRepository {
3493
+ get(projectKey: string): Promise<MarketingSettingsRow | null>;
3494
+ upsert(projectKey: string, data: UpdateMarketingSettingsData): Promise<MarketingSettingsRow>;
3495
+ }
3496
+
3497
+ /**
3498
+ * Adapter for `checkout_offers`. The offer is an immutable bundle snapshot:
3499
+ * `create` creates it, `update` only allows customization while
3500
+ * `status = 'open'`, `consume` freezes it.
3501
+ */
3502
+ interface CheckoutOfferRepository {
3503
+ list(filter: CheckoutOfferFilter): Promise<CheckoutOfferRow[]>;
3504
+ findById(id: string): Promise<CheckoutOfferRow | null>;
3505
+ create(data: CreateCheckoutOfferData): Promise<CheckoutOfferRow>;
3506
+ update(id: string, data: UpdateCheckoutOfferData): Promise<CheckoutOfferRow>;
3507
+ /** Sets `status = 'consumed'` + `consumedAt = NOW()`. */
3508
+ consume(id: string): Promise<CheckoutOfferRow>;
3509
+ }
3510
+
3511
+ /** Class reference usable as a DI token (e.g. the consumer's `PrismaService`). */
3512
+ type PersistenceClassRef = abstract new (...args: never[]) => unknown;
3513
+ /** DI token forms a persistence bundle may reference in `inject`. */
3514
+ type PersistenceInjectionToken = string | symbol | PersistenceClassRef;
3515
+ /**
3516
+ * Framework-free equivalent of the NestJS `ProviderSpec<T>`: either a ready
3517
+ * instance or a `{ useFactory, inject }` factory description. Structurally
3518
+ * assignable to `ProviderSpec<T>` in `@saasicat/nest`, so bundle fields can
3519
+ * be passed to every `*.forRoot()` option unchanged.
3520
+ */
3521
+ type PersistenceProvider<T> = T | {
3522
+ useFactory: (...deps: never[]) => T | Promise<T>;
3523
+ inject?: PersistenceInjectionToken[];
3524
+ };
3525
+ /**
3526
+ * Capabilities a persistence adapter guarantees. The platform fail-fasts at
3527
+ * boot when a required capability is missing, instead of silently degrading
3528
+ * correctness (e.g. quota enforcement without row locks).
3529
+ *
3530
+ * These flags describe the ADAPTER + DATABASE combination, not the abstract
3531
+ * ORM: an adapter may set `pessimisticLocking: false` when it targets a
3532
+ * backend without `SELECT ... FOR UPDATE` semantics.
3533
+ */
3534
+ interface PersistenceCapabilities {
3535
+ /**
3536
+ * `TransactionRunner.run` opens a real ACID transaction: throwing inside
3537
+ * the callback rolls back every write performed through the passed
3538
+ * `TransactionContext`.
3539
+ */
3540
+ transactions: boolean;
3541
+ /**
3542
+ * `SubscriptionRepository.findByTenantIdLocked` takes a row lock
3543
+ * (`SELECT ... FOR UPDATE`) that serializes concurrent transactions on
3544
+ * the same tenant. Required for the transactional `enforceLimit()` path.
3545
+ */
3546
+ pessimisticLocking: boolean;
3547
+ /**
3548
+ * The adapter stack integrates with Postgres row-level security (the
3549
+ * `RlsBypassPort` frame actually lifts RLS for SuperAdmin reads).
3550
+ * Informational — RLS policies themselves remain consumer-owned.
3551
+ */
3552
+ rowLevelSecurity: boolean;
3553
+ /** Advisory-lock support (`pg_advisory_*`). No platform path requires it today. */
3554
+ advisoryLocks: boolean;
3555
+ }
3556
+ /** Always-required slice: admin surface + transactions. */
3557
+ interface SaaSiCatPersistenceCore {
3558
+ mfa: PersistenceProvider<MfaPort>;
3559
+ audit: PersistenceProvider<AuditPort>;
3560
+ rlsBypass: PersistenceProvider<RlsBypassPort>;
3561
+ transactionRunner: PersistenceProvider<TransactionRunner>;
3562
+ /** First-run setup wizard (`SetupModule`). */
3563
+ superAdminProvisioning?: PersistenceProvider<SuperAdminProvisioningPort>;
3564
+ /** Read side for `<app> audit tail` and admin audit pages. */
3565
+ auditQuery?: PersistenceProvider<AuditQueryPort>;
3566
+ /** Aggregation for the admin stats dashboard. */
3567
+ auditStats?: PersistenceProvider<AuditStatsPort>;
3568
+ }
3569
+ /** Repositories for the entitlement/contract loop (`EntitlementModule`). */
3570
+ interface SaaSiCatPersistenceEntitlement {
3571
+ subscriptionRepository: PersistenceProvider<SubscriptionRepository>;
3572
+ planVersionRepository: PersistenceProvider<PlanVersionRepository>;
3573
+ subscriptionContractRepository?: PersistenceProvider<SubscriptionContractRepository>;
3574
+ subscriptionBundleRepository?: PersistenceProvider<SubscriptionBundleRepository>;
3575
+ bundleRepository?: PersistenceProvider<BundleRepository>;
3576
+ }
3577
+ /**
3578
+ * Repositories for the editable catalog plane. A complete adapter can expose
3579
+ * this slice once and let the high-level Nest module wire `CatalogModule` and
3580
+ * `PublicCatalogModule` without consumer-owned forwarding modules.
3581
+ */
3582
+ interface SaaSiCatPersistenceCatalog {
3583
+ planRepository: PersistenceProvider<PlanRepository>;
3584
+ bundleRepository: PersistenceProvider<BundleRepository>;
3585
+ catalogEntryRepository?: PersistenceProvider<CatalogEntryRepository>;
3586
+ marketingProjectionRepository?: PersistenceProvider<MarketingProjectionRepository>;
3587
+ promotionRepository?: PersistenceProvider<PromotionRepository>;
3588
+ marketingSettingsRepository?: PersistenceProvider<MarketingSettingsRepository>;
3589
+ }
3590
+ /**
3591
+ * Standard tenant self-service persistence. `usageSnapshotPort` is optional:
3592
+ * the Nest high-level module derives it from registered `QuotaProvider`s when
3593
+ * the adapter does not provide a more specialized implementation.
3594
+ */
3595
+ interface SaaSiCatPersistenceTenantBilling {
3596
+ subscriptionUsagePort: PersistenceProvider<SubscriptionUsagePort>;
3597
+ subscriptionWritePort: PersistenceProvider<TenantSubscriptionWritePort>;
3598
+ usageSnapshotPort?: PersistenceProvider<UsageSnapshotPort>;
3599
+ }
3600
+ /** Read/write backing for the standard SuperAdmin resource pages. */
3601
+ interface SaaSiCatPersistenceAdminResources {
3602
+ resources: PersistenceProvider<AdminResourcesPort>;
3603
+ }
3604
+ /**
3605
+ * Repositories for `PromoCodesModule.forRoot`. Field names match the module
3606
+ * options so the slice can be spread into the options object. The
3607
+ * app-semantic `firstTimeCustomerCheck` is deliberately NOT part of the
3608
+ * bundle — what counts as an existing customer is a consumer decision.
3609
+ */
3610
+ interface SaaSiCatPersistencePromo {
3611
+ promoCodeRepository: PersistenceProvider<PromoCodeRepository>;
3612
+ redemptionRepository: PersistenceProvider<PromoCodeRedemptionRepository>;
3613
+ validationLogRepository: PersistenceProvider<PromoCodeValidationLogRepository>;
3614
+ subscriptionLookup: PersistenceProvider<PromoSubscriptionLookup>;
3615
+ revenueAggregator: PersistenceProvider<PromoRevenueDeductionAggregator>;
3616
+ }
3617
+ /**
3618
+ * Aggregate persistence bundle. Produced by adapter factories such as
3619
+ * `prismaPersistence({ client })`; consumed by
3620
+ * `SaaSiCatModule.forRoot({ persistence })`. Fine-grained fields can still be
3621
+ * passed to per-domain `forRoot` options for custom compositions.
3622
+ */
3623
+ interface SaaSiCatPersistenceAdapter {
3624
+ capabilities: PersistenceCapabilities;
3625
+ core: SaaSiCatPersistenceCore;
3626
+ entitlement?: SaaSiCatPersistenceEntitlement;
3627
+ /** Editable plans, bundles, discovery review and marketing data. */
3628
+ catalog?: SaaSiCatPersistenceCatalog;
3629
+ /** Tenant subscription read/write adapters for the self-service API. */
3630
+ tenantBilling?: SaaSiCatPersistenceTenantBilling;
3631
+ /** Tenant, user, audit and subscription resources for the SuperAdmin UI. */
3632
+ adminResources?: SaaSiCatPersistenceAdminResources;
3633
+ promo?: SaaSiCatPersistencePromo;
3634
+ /** DB hydration of the plan catalog at boot (`PlanCatalogModule`). */
3635
+ planCatalogReadSink?: PersistenceProvider<PlanCatalogReadSink>;
3636
+ /** One-shot `saas.yaml → DB` import. */
3637
+ planCatalogImportSink?: PersistenceProvider<PlanCatalogImportSink>;
3638
+ }
3639
+ /** Options for capability assertions raised by platform modules at boot. */
3640
+ interface RequiredCapabilities {
3641
+ transactions?: boolean;
3642
+ pessimisticLocking?: boolean;
3643
+ rowLevelSecurity?: boolean;
3644
+ advisoryLocks?: boolean;
3645
+ }
3646
+ /**
3647
+ * Thrown when a persistence adapter does not provide a capability the
3648
+ * enabled platform feature set requires. Framework-free so both nest and
3649
+ * CLI surfaces can map it.
3650
+ */
3651
+ declare class PersistenceCapabilityError extends Error {
3652
+ readonly missing: Array<keyof PersistenceCapabilities>;
3653
+ readonly requiredBy: string;
3654
+ readonly code = "PERSISTENCE_CAPABILITY_MISSING";
3655
+ constructor(missing: Array<keyof PersistenceCapabilities>, requiredBy: string);
3656
+ }
3657
+ /**
3658
+ * Validates declared capabilities against a feature's requirements. Throws
3659
+ * `PersistenceCapabilityError` listing every missing capability.
3660
+ */
3661
+ declare function assertPersistenceCapabilities(capabilities: PersistenceCapabilities, required: RequiredCapabilities, requiredBy: string): void;
3662
+
3663
+ /** Codes of the first-run setup endpoints (`SetupController`). */
3664
+ declare const SETUP_ERROR_CODES: {
3665
+ /** `SETUP_TOKEN` env not set → setup disabled. */
3666
+ readonly SETUP_DISABLED: "SETUP_DISABLED";
3667
+ /** Provided token does not match. */
3668
+ readonly INVALID_SETUP_TOKEN: "INVALID_SETUP_TOKEN";
3669
+ /** A SUPER_ADMIN already exists — self-disable. */
3670
+ readonly SETUP_ALREADY_DONE: "SETUP_ALREADY_DONE";
3671
+ /** Invalid email in the request. */
3672
+ readonly INVALID_EMAIL: "INVALID_EMAIL";
3673
+ /** Email already taken (mapped from `PlatformUserExistsError`). */
3674
+ readonly EMAIL_EXISTS: "EMAIL_EXISTS";
3675
+ };
3676
+ type SetupErrorCode = (typeof SETUP_ERROR_CODES)[keyof typeof SETUP_ERROR_CODES];
3677
+ /** Plan and bundle lifecycle in the admin catalogue. */
3678
+ declare const CATALOG_ERROR_CODES: {
3679
+ readonly PLAN_HAS_DRAFTS: "PLAN_HAS_DRAFTS";
3680
+ readonly PLAN_HAS_PUBLISHED_VERSIONS: "PLAN_HAS_PUBLISHED_VERSIONS";
3681
+ readonly PLAN_HARD_DELETE_NOT_IMPLEMENTED: "PLAN_HARD_DELETE_NOT_IMPLEMENTED";
3682
+ readonly PLAN_VERSION_ALREADY_PUBLISHED: "PLAN_VERSION_ALREADY_PUBLISHED";
3683
+ readonly PLAN_VERSION_NOT_EDITABLE: "PLAN_VERSION_NOT_EDITABLE";
3684
+ readonly PLAN_VERSION_REGRESSION: "PLAN_VERSION_REGRESSION";
3685
+ readonly PLAN_VERSION_ZERO_PRICE: "PLAN_VERSION_ZERO_PRICE";
3686
+ readonly PLAN_VERSION_DISCARD_NOT_IMPLEMENTED: "PLAN_VERSION_DISCARD_NOT_IMPLEMENTED";
3687
+ /** Version was never published, so it cannot be terminated. */
3688
+ readonly PLAN_VERSION_NOT_PUBLISHED: "PLAN_VERSION_NOT_PUBLISHED";
3689
+ /** Version was replaced by a successor (`supersededAt` set). */
3690
+ readonly PLAN_VERSION_SUPERSEDED: "PLAN_VERSION_SUPERSEDED";
3691
+ readonly PLAN_VERSION_VALID_FROM_REQUIRED: "PLAN_VERSION_VALID_FROM_REQUIRED";
3692
+ readonly PLAN_VERSION_VALID_FROM_INVALID: "PLAN_VERSION_VALID_FROM_INVALID";
3693
+ readonly PLAN_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS: "PLAN_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS";
3694
+ readonly PLAN_VERSION_VALID_FROM_NOT_GAPLESS: "PLAN_VERSION_VALID_FROM_NOT_GAPLESS";
3695
+ readonly PLAN_VERSION_VALID_UNTIL_INVALID: "PLAN_VERSION_VALID_UNTIL_INVALID";
3696
+ readonly PLAN_VERSION_VALID_UNTIL_BEFORE_FROM: "PLAN_VERSION_VALID_UNTIL_BEFORE_FROM";
3697
+ readonly PLAN_TERMINATE_INVALID_DATE: "PLAN_TERMINATE_INVALID_DATE";
3698
+ readonly PLAN_TERMINATE_DATE_NOT_FUTURE: "PLAN_TERMINATE_DATE_NOT_FUTURE";
3699
+ readonly PLAN_TERMINATE_NOT_IMPLEMENTED: "PLAN_TERMINATE_NOT_IMPLEMENTED";
3700
+ readonly BUNDLE_VERSION_ALREADY_PUBLISHED: "BUNDLE_VERSION_ALREADY_PUBLISHED";
3701
+ readonly BUNDLE_VERSION_NOT_EDITABLE: "BUNDLE_VERSION_NOT_EDITABLE";
3702
+ readonly BUNDLE_VERSION_NOT_PUBLISHED: "BUNDLE_VERSION_NOT_PUBLISHED";
3703
+ readonly BUNDLE_VERSION_SUPERSEDED: "BUNDLE_VERSION_SUPERSEDED";
3704
+ readonly BUNDLE_VERSION_REGRESSION: "BUNDLE_VERSION_REGRESSION";
3705
+ readonly BUNDLE_VERSION_ZERO_PRICE: "BUNDLE_VERSION_ZERO_PRICE";
3706
+ readonly BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED: "BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED";
3707
+ readonly BUNDLE_VERSION_VALID_FROM_REQUIRED: "BUNDLE_VERSION_VALID_FROM_REQUIRED";
3708
+ readonly BUNDLE_VERSION_VALID_FROM_INVALID: "BUNDLE_VERSION_VALID_FROM_INVALID";
3709
+ readonly BUNDLE_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS: "BUNDLE_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS";
3710
+ readonly BUNDLE_VERSION_VALID_FROM_NOT_GAPLESS: "BUNDLE_VERSION_VALID_FROM_NOT_GAPLESS";
3711
+ readonly BUNDLE_VERSION_VALID_FROM_NOT_FUTURE: "BUNDLE_VERSION_VALID_FROM_NOT_FUTURE";
3712
+ readonly BUNDLE_VERSION_VALID_UNTIL_INVALID: "BUNDLE_VERSION_VALID_UNTIL_INVALID";
3713
+ readonly BUNDLE_VERSION_VALID_UNTIL_BEFORE_FROM: "BUNDLE_VERSION_VALID_UNTIL_BEFORE_FROM";
3714
+ /** Publish blocked by strict mode. Carries `warnings[]` with own codes. */
3715
+ readonly STRICT_MODE_VIOLATIONS: "STRICT_MODE_VIOLATIONS";
3716
+ readonly PLAN_NOT_FOUND: "PLAN_NOT_FOUND";
3717
+ readonly PLAN_VERSION_NOT_FOUND: "PLAN_VERSION_NOT_FOUND";
3718
+ readonly BUNDLE_NOT_FOUND: "BUNDLE_NOT_FOUND";
3719
+ readonly BUNDLE_VERSION_NOT_FOUND: "BUNDLE_VERSION_NOT_FOUND";
3720
+ readonly FEATURE_NOT_FOUND: "FEATURE_NOT_FOUND";
3721
+ readonly QUOTA_NOT_FOUND: "QUOTA_NOT_FOUND";
3722
+ readonly PROMOTION_NOT_FOUND: "PROMOTION_NOT_FOUND";
3723
+ readonly MARKETING_PROJECTION_NOT_FOUND: "MARKETING_PROJECTION_NOT_FOUND";
3724
+ readonly PLAN_ALREADY_EXISTS: "PLAN_ALREADY_EXISTS";
3725
+ readonly BUNDLE_ALREADY_EXISTS: "BUNDLE_ALREADY_EXISTS";
3726
+ readonly MARKETING_PROJECTION_ALREADY_EXISTS: "MARKETING_PROJECTION_ALREADY_EXISTS";
3727
+ /** A draft already exists — publish or discard it before creating another. */
3728
+ readonly PLAN_DRAFT_ALREADY_EXISTS: "PLAN_DRAFT_ALREADY_EXISTS";
3729
+ readonly BUNDLE_DRAFT_ALREADY_EXISTS: "BUNDLE_DRAFT_ALREADY_EXISTS";
3730
+ readonly QUOTA_NOT_IN_DISCOVERY_SNAPSHOT: "QUOTA_NOT_IN_DISCOVERY_SNAPSHOT";
3731
+ readonly DISCOVERY_STATUS_TRANSITION_INVALID: "DISCOVERY_STATUS_TRANSITION_INVALID";
3732
+ readonly DISCOVERY_NOT_INITIALIZED: "DISCOVERY_NOT_INITIALIZED";
3733
+ };
3734
+ type CatalogErrorCode = (typeof CATALOG_ERROR_CODES)[keyof typeof CATALOG_ERROR_CODES];
3735
+ /** Bundle bookings on a tenant subscription. */
3736
+ declare const BILLING_ERROR_CODES: {
3737
+ /**
3738
+ * Feature not covered by the plan. Defined in `upsell.types.ts` because the
3739
+ * upsell body type is built around it; re-exported here so an exhaustive
3740
+ * `PlatformErrorCode` switch covers it.
3741
+ */
3742
+ readonly FEATURE_NOT_LICENSED: "FEATURE_NOT_LICENSED";
3743
+ readonly BUNDLE_ALREADY_SUBSCRIBED: "BUNDLE_ALREADY_SUBSCRIBED";
3744
+ readonly BUNDLE_INCOMPATIBLE_WITH_PLAN: "BUNDLE_INCOMPATIBLE_WITH_PLAN";
3745
+ readonly BUNDLE_NOT_SELF_SERVICE: "BUNDLE_NOT_SELF_SERVICE";
3746
+ readonly SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED: "SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED";
3747
+ readonly SUBSCRIPTION_BUNDLE_NOT_CANCELLED: "SUBSCRIPTION_BUNDLE_NOT_CANCELLED";
3748
+ readonly SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE: "SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE";
3749
+ readonly SUBSCRIPTION_NOT_FOUND: "SUBSCRIPTION_NOT_FOUND";
3750
+ readonly SUBSCRIPTION_TENANT_MISMATCH: "SUBSCRIPTION_TENANT_MISMATCH";
3751
+ readonly SUBSCRIPTION_BUNDLE_NOT_FOUND: "SUBSCRIPTION_BUNDLE_NOT_FOUND";
3752
+ readonly TENANT_NOT_FOUND: "TENANT_NOT_FOUND";
3753
+ /** No plan version is active as of the requested date. */
3754
+ readonly NO_ACTIVE_PLAN_VERSION: "NO_ACTIVE_PLAN_VERSION";
3755
+ /** Plan is unknown to the loaded plan catalogue. */
3756
+ readonly PLAN_NOT_IN_CATALOG: "PLAN_NOT_IN_CATALOG";
3757
+ /** Plan exists but cannot be booked via self-service. */
3758
+ readonly PLAN_NOT_SELF_SERVICE: "PLAN_NOT_SELF_SERVICE";
3759
+ /** Plan change refused. Carries `blockers[]` with their own codes. */
3760
+ readonly PLAN_CHANGE_BLOCKED: "PLAN_CHANGE_BLOCKED";
3761
+ readonly NO_PENDING_PLAN_VERSION: "NO_PENDING_PLAN_VERSION";
3762
+ readonly ONBOARDING_CREATE_FAILED: "ONBOARDING_CREATE_FAILED";
3763
+ readonly BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS: "BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS";
3764
+ /**
3765
+ * The adapter returned a `SubscriptionUsageRecord` without `id`. A wiring
3766
+ * error in the consumer, not a missing subscription — hence its own code.
3767
+ */
3768
+ readonly SUBSCRIPTION_PK_MISSING: "SUBSCRIPTION_PK_MISSING";
3769
+ /** Quota exhausted. Carries `dimension`, `used`, `max`. */
3770
+ readonly LIMIT_EXCEEDED: "LIMIT_EXCEEDED";
3771
+ readonly QUOTA_DIMENSION_UNKNOWN: "QUOTA_DIMENSION_UNKNOWN";
3772
+ };
3773
+ type BillingErrorCode = (typeof BILLING_ERROR_CODES)[keyof typeof BILLING_ERROR_CODES];
3774
+ /** Checkout offers and the subscription contracts derived from them. */
3775
+ declare const CONTRACT_ERROR_CODES: {
3776
+ readonly CHECKOUT_OFFER_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_LINE_ITEMS_REQUIRED";
3777
+ readonly CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED: "CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED";
3778
+ readonly CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED";
3779
+ readonly CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE: "CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE";
3780
+ readonly CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED: "CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED";
3781
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED: "SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED";
3782
+ readonly SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED: "SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED";
3783
+ readonly SUBSCRIPTION_CONTRACT_INVALID_DATE: "SUBSCRIPTION_CONTRACT_INVALID_DATE";
3784
+ readonly SUBSCRIPTION_CONTRACT_INVALID_WINDOW: "SUBSCRIPTION_CONTRACT_INVALID_WINDOW";
3785
+ readonly SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START";
3786
+ readonly CHECKOUT_OFFER_NOT_FOUND: "CHECKOUT_OFFER_NOT_FOUND";
3787
+ readonly CHECKOUT_OFFER_EXPIRED: "CHECKOUT_OFFER_EXPIRED";
3788
+ readonly CHECKOUT_OFFER_ALREADY_CONSUMED: "CHECKOUT_OFFER_ALREADY_CONSUMED";
3789
+ readonly CHECKOUT_OFFER_NOT_CONSUMED: "CHECKOUT_OFFER_NOT_CONSUMED";
3790
+ readonly SUBSCRIPTION_CONTRACT_NOT_FOUND: "SUBSCRIPTION_CONTRACT_NOT_FOUND";
3791
+ readonly NO_ACTIVE_SUBSCRIPTION_CONTRACT: "NO_ACTIVE_SUBSCRIPTION_CONTRACT";
3792
+ readonly SUBSCRIPTION_CONTRACT_ALREADY_CLOSED: "SUBSCRIPTION_CONTRACT_ALREADY_CLOSED";
3793
+ };
3794
+ type ContractErrorCode = (typeof CONTRACT_ERROR_CODES)[keyof typeof CONTRACT_ERROR_CODES];
3795
+ /** Self-service registration funnel (`PendingRegistration`). */
3796
+ declare const REGISTRATION_ERROR_CODES: {
3797
+ readonly PENDING_REGISTRATION_NOT_FOUND: "PENDING_REGISTRATION_NOT_FOUND";
3798
+ readonly PENDING_REGISTRATION_EXPIRED: "PENDING_REGISTRATION_EXPIRED";
3799
+ readonly INVALID_REGISTRATION_STATE: "INVALID_REGISTRATION_STATE";
3800
+ readonly OTP_INVALID: "OTP_INVALID";
3801
+ readonly OTP_EXPIRED: "OTP_EXPIRED";
3802
+ readonly OTP_LOCKED: "OTP_LOCKED";
3803
+ /** Too many attempts from this origin. Carries `retryAfterSeconds`. */
3804
+ readonly RATE_LIMITED: "RATE_LIMITED";
3805
+ readonly RESUME_TOKEN_INVALID: "RESUME_TOKEN_INVALID";
3806
+ readonly RESUME_NOT_CONFIGURED: "RESUME_NOT_CONFIGURED";
3807
+ readonly CONFIGURATOR_NOT_CONFIGURED: "CONFIGURATOR_NOT_CONFIGURED";
3808
+ readonly CONFIG_NOT_SAVED: "CONFIG_NOT_SAVED";
3809
+ readonly PLAN_NOT_AVAILABLE: "PLAN_NOT_AVAILABLE";
3810
+ readonly PLAN_NOT_SELECTED: "PLAN_NOT_SELECTED";
3811
+ readonly MODEL_NOT_AVAILABLE: "MODEL_NOT_AVAILABLE";
3812
+ };
3813
+ type RegistrationErrorCode = (typeof REGISTRATION_ERROR_CODES)[keyof typeof REGISTRATION_ERROR_CODES];
3814
+ /** Authentication, role and tenant-context failures raised by the guards. */
3815
+ declare const AUTH_ERROR_CODES: {
3816
+ /** No authenticated user on the request. */
3817
+ readonly NOT_AUTHENTICATED: "NOT_AUTHENTICATED";
3818
+ /** Authenticated, but the request carries no tenant. */
3819
+ readonly NO_TENANT_ASSIGNED: "NO_TENANT_ASSIGNED";
3820
+ /** Neither `tenantId` nor `userId` could be resolved from the request. */
3821
+ readonly TENANT_CONTEXT_MISSING: "TENANT_CONTEXT_MISSING";
3822
+ readonly TENANT_ADMIN_REQUIRED: "TENANT_ADMIN_REQUIRED";
3823
+ readonly SUPER_ADMIN_REQUIRED: "SUPER_ADMIN_REQUIRED";
3824
+ /** TOTP MFA has never been set up for this user. */
3825
+ readonly MFA_NOT_SET_UP: "MFA_NOT_SET_UP";
3826
+ /** Endpoint is MFA-gated and no `X-Mfa-Code` header was sent. */
3827
+ readonly MFA_REQUIRED: "MFA_REQUIRED";
3828
+ /** The supplied TOTP code did not verify. */
3829
+ readonly MFA_FAILED: "MFA_FAILED";
3830
+ /** Module misconfiguration, not an end-user condition. */
3831
+ readonly AUTH_GUARDS_NOT_CONFIGURED: "AUTH_GUARDS_NOT_CONFIGURED";
3832
+ };
3833
+ type AuthErrorCode = (typeof AUTH_ERROR_CODES)[keyof typeof AUTH_ERROR_CODES];
3834
+ /** Promo-code administration and redemption. */
3835
+ declare const PROMO_ERROR_CODES: {
3836
+ readonly PROMO_CODE_NOT_FOUND: "PROMO_CODE_NOT_FOUND";
3837
+ readonly PROMO_CODE_ALREADY_EXISTS: "PROMO_CODE_ALREADY_EXISTS";
3838
+ readonly PROMO_CODE_HAS_REDEMPTIONS: "PROMO_CODE_HAS_REDEMPTIONS";
3839
+ /** Not redeemable. Carries `reason` (`PromoPreviewInvalidReason`). */
3840
+ readonly PROMO_CODE_NOT_REDEEMABLE: "PROMO_CODE_NOT_REDEEMABLE";
3841
+ readonly PROMO_CODE_FORMAT_INVALID: "PROMO_CODE_FORMAT_INVALID";
3842
+ readonly PROMO_PERCENT_OUT_OF_RANGE: "PROMO_PERCENT_OUT_OF_RANGE";
3843
+ readonly PROMO_AMOUNT_NOT_POSITIVE: "PROMO_AMOUNT_NOT_POSITIVE";
3844
+ readonly PROMO_ONE_OFF_WITH_DURATION: "PROMO_ONE_OFF_WITH_DURATION";
3845
+ readonly PROMO_DURATION_INVALID: "PROMO_DURATION_INVALID";
3846
+ readonly PROMO_VALIDITY_WINDOW_INVALID: "PROMO_VALIDITY_WINDOW_INVALID";
3847
+ readonly PROMO_PLAN_NOT_DISCOUNTABLE: "PROMO_PLAN_NOT_DISCOUNTABLE";
3848
+ readonly PROMO_MIN_AMOUNT_NOT_POSITIVE: "PROMO_MIN_AMOUNT_NOT_POSITIVE";
3849
+ readonly PROMO_WOULD_PRODUCE_ZERO_INVOICE: "PROMO_WOULD_PRODUCE_ZERO_INVOICE";
3850
+ readonly PROMO_MAX_REDEMPTIONS_LOWERED: "PROMO_MAX_REDEMPTIONS_LOWERED";
3851
+ };
3852
+ type PromoErrorCode = (typeof PROMO_ERROR_CODES)[keyof typeof PROMO_ERROR_CODES];
3853
+ /**
3854
+ * Every exception code the platform emits, in one object.
3855
+ *
3856
+ * Group membership is presentational — the wire format is the bare string, so
3857
+ * a code may be moved between groups without breaking consumers. Renaming or
3858
+ * removing one may not.
3859
+ */
3860
+ declare const PLATFORM_ERROR_CODES: {
3861
+ readonly PENDING_REGISTRATION_NOT_FOUND: "PENDING_REGISTRATION_NOT_FOUND";
3862
+ readonly PENDING_REGISTRATION_EXPIRED: "PENDING_REGISTRATION_EXPIRED";
3863
+ readonly INVALID_REGISTRATION_STATE: "INVALID_REGISTRATION_STATE";
3864
+ readonly OTP_INVALID: "OTP_INVALID";
3865
+ readonly OTP_EXPIRED: "OTP_EXPIRED";
3866
+ readonly OTP_LOCKED: "OTP_LOCKED";
3867
+ /** Too many attempts from this origin. Carries `retryAfterSeconds`. */
3868
+ readonly RATE_LIMITED: "RATE_LIMITED";
3869
+ readonly RESUME_TOKEN_INVALID: "RESUME_TOKEN_INVALID";
3870
+ readonly RESUME_NOT_CONFIGURED: "RESUME_NOT_CONFIGURED";
3871
+ readonly CONFIGURATOR_NOT_CONFIGURED: "CONFIGURATOR_NOT_CONFIGURED";
3872
+ readonly CONFIG_NOT_SAVED: "CONFIG_NOT_SAVED";
3873
+ readonly PLAN_NOT_AVAILABLE: "PLAN_NOT_AVAILABLE";
3874
+ readonly PLAN_NOT_SELECTED: "PLAN_NOT_SELECTED";
3875
+ readonly MODEL_NOT_AVAILABLE: "MODEL_NOT_AVAILABLE";
3876
+ readonly CHECKOUT_OFFER_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_LINE_ITEMS_REQUIRED";
3877
+ readonly CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED: "CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED";
3878
+ readonly CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED";
3879
+ readonly CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE: "CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE";
3880
+ readonly CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED: "CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED";
3881
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED: "SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED";
3882
+ readonly SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED: "SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED";
3883
+ readonly SUBSCRIPTION_CONTRACT_INVALID_DATE: "SUBSCRIPTION_CONTRACT_INVALID_DATE";
3884
+ readonly SUBSCRIPTION_CONTRACT_INVALID_WINDOW: "SUBSCRIPTION_CONTRACT_INVALID_WINDOW";
3885
+ readonly SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START";
3886
+ readonly CHECKOUT_OFFER_NOT_FOUND: "CHECKOUT_OFFER_NOT_FOUND";
3887
+ readonly CHECKOUT_OFFER_EXPIRED: "CHECKOUT_OFFER_EXPIRED";
3888
+ readonly CHECKOUT_OFFER_ALREADY_CONSUMED: "CHECKOUT_OFFER_ALREADY_CONSUMED";
3889
+ readonly CHECKOUT_OFFER_NOT_CONSUMED: "CHECKOUT_OFFER_NOT_CONSUMED";
3890
+ readonly SUBSCRIPTION_CONTRACT_NOT_FOUND: "SUBSCRIPTION_CONTRACT_NOT_FOUND";
3891
+ readonly NO_ACTIVE_SUBSCRIPTION_CONTRACT: "NO_ACTIVE_SUBSCRIPTION_CONTRACT";
3892
+ readonly SUBSCRIPTION_CONTRACT_ALREADY_CLOSED: "SUBSCRIPTION_CONTRACT_ALREADY_CLOSED";
3893
+ /**
3894
+ * Feature not covered by the plan. Defined in `upsell.types.ts` because the
3895
+ * upsell body type is built around it; re-exported here so an exhaustive
3896
+ * `PlatformErrorCode` switch covers it.
3897
+ */
3898
+ readonly FEATURE_NOT_LICENSED: "FEATURE_NOT_LICENSED";
3899
+ readonly BUNDLE_ALREADY_SUBSCRIBED: "BUNDLE_ALREADY_SUBSCRIBED";
3900
+ readonly BUNDLE_INCOMPATIBLE_WITH_PLAN: "BUNDLE_INCOMPATIBLE_WITH_PLAN";
3901
+ readonly BUNDLE_NOT_SELF_SERVICE: "BUNDLE_NOT_SELF_SERVICE";
3902
+ readonly SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED: "SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED";
3903
+ readonly SUBSCRIPTION_BUNDLE_NOT_CANCELLED: "SUBSCRIPTION_BUNDLE_NOT_CANCELLED";
3904
+ readonly SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE: "SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE";
3905
+ readonly SUBSCRIPTION_NOT_FOUND: "SUBSCRIPTION_NOT_FOUND";
3906
+ readonly SUBSCRIPTION_TENANT_MISMATCH: "SUBSCRIPTION_TENANT_MISMATCH";
3907
+ readonly SUBSCRIPTION_BUNDLE_NOT_FOUND: "SUBSCRIPTION_BUNDLE_NOT_FOUND";
3908
+ readonly TENANT_NOT_FOUND: "TENANT_NOT_FOUND";
3909
+ /** No plan version is active as of the requested date. */
3910
+ readonly NO_ACTIVE_PLAN_VERSION: "NO_ACTIVE_PLAN_VERSION";
3911
+ /** Plan is unknown to the loaded plan catalogue. */
3912
+ readonly PLAN_NOT_IN_CATALOG: "PLAN_NOT_IN_CATALOG";
3913
+ /** Plan exists but cannot be booked via self-service. */
3914
+ readonly PLAN_NOT_SELF_SERVICE: "PLAN_NOT_SELF_SERVICE";
3915
+ /** Plan change refused. Carries `blockers[]` with their own codes. */
3916
+ readonly PLAN_CHANGE_BLOCKED: "PLAN_CHANGE_BLOCKED";
3917
+ readonly NO_PENDING_PLAN_VERSION: "NO_PENDING_PLAN_VERSION";
3918
+ readonly ONBOARDING_CREATE_FAILED: "ONBOARDING_CREATE_FAILED";
3919
+ readonly BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS: "BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS";
3920
+ /**
3921
+ * The adapter returned a `SubscriptionUsageRecord` without `id`. A wiring
3922
+ * error in the consumer, not a missing subscription — hence its own code.
3923
+ */
3924
+ readonly SUBSCRIPTION_PK_MISSING: "SUBSCRIPTION_PK_MISSING";
3925
+ /** Quota exhausted. Carries `dimension`, `used`, `max`. */
3926
+ readonly LIMIT_EXCEEDED: "LIMIT_EXCEEDED";
3927
+ readonly QUOTA_DIMENSION_UNKNOWN: "QUOTA_DIMENSION_UNKNOWN";
3928
+ readonly PLAN_HAS_DRAFTS: "PLAN_HAS_DRAFTS";
3929
+ readonly PLAN_HAS_PUBLISHED_VERSIONS: "PLAN_HAS_PUBLISHED_VERSIONS";
3930
+ readonly PLAN_HARD_DELETE_NOT_IMPLEMENTED: "PLAN_HARD_DELETE_NOT_IMPLEMENTED";
3931
+ readonly PLAN_VERSION_ALREADY_PUBLISHED: "PLAN_VERSION_ALREADY_PUBLISHED";
3932
+ readonly PLAN_VERSION_NOT_EDITABLE: "PLAN_VERSION_NOT_EDITABLE";
3933
+ readonly PLAN_VERSION_REGRESSION: "PLAN_VERSION_REGRESSION";
3934
+ readonly PLAN_VERSION_ZERO_PRICE: "PLAN_VERSION_ZERO_PRICE";
3935
+ readonly PLAN_VERSION_DISCARD_NOT_IMPLEMENTED: "PLAN_VERSION_DISCARD_NOT_IMPLEMENTED";
3936
+ /** Version was never published, so it cannot be terminated. */
3937
+ readonly PLAN_VERSION_NOT_PUBLISHED: "PLAN_VERSION_NOT_PUBLISHED";
3938
+ /** Version was replaced by a successor (`supersededAt` set). */
3939
+ readonly PLAN_VERSION_SUPERSEDED: "PLAN_VERSION_SUPERSEDED";
3940
+ readonly PLAN_VERSION_VALID_FROM_REQUIRED: "PLAN_VERSION_VALID_FROM_REQUIRED";
3941
+ readonly PLAN_VERSION_VALID_FROM_INVALID: "PLAN_VERSION_VALID_FROM_INVALID";
3942
+ readonly PLAN_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS: "PLAN_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS";
3943
+ readonly PLAN_VERSION_VALID_FROM_NOT_GAPLESS: "PLAN_VERSION_VALID_FROM_NOT_GAPLESS";
3944
+ readonly PLAN_VERSION_VALID_UNTIL_INVALID: "PLAN_VERSION_VALID_UNTIL_INVALID";
3945
+ readonly PLAN_VERSION_VALID_UNTIL_BEFORE_FROM: "PLAN_VERSION_VALID_UNTIL_BEFORE_FROM";
3946
+ readonly PLAN_TERMINATE_INVALID_DATE: "PLAN_TERMINATE_INVALID_DATE";
3947
+ readonly PLAN_TERMINATE_DATE_NOT_FUTURE: "PLAN_TERMINATE_DATE_NOT_FUTURE";
3948
+ readonly PLAN_TERMINATE_NOT_IMPLEMENTED: "PLAN_TERMINATE_NOT_IMPLEMENTED";
3949
+ readonly BUNDLE_VERSION_ALREADY_PUBLISHED: "BUNDLE_VERSION_ALREADY_PUBLISHED";
3950
+ readonly BUNDLE_VERSION_NOT_EDITABLE: "BUNDLE_VERSION_NOT_EDITABLE";
3951
+ readonly BUNDLE_VERSION_NOT_PUBLISHED: "BUNDLE_VERSION_NOT_PUBLISHED";
3952
+ readonly BUNDLE_VERSION_SUPERSEDED: "BUNDLE_VERSION_SUPERSEDED";
3953
+ readonly BUNDLE_VERSION_REGRESSION: "BUNDLE_VERSION_REGRESSION";
3954
+ readonly BUNDLE_VERSION_ZERO_PRICE: "BUNDLE_VERSION_ZERO_PRICE";
3955
+ readonly BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED: "BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED";
3956
+ readonly BUNDLE_VERSION_VALID_FROM_REQUIRED: "BUNDLE_VERSION_VALID_FROM_REQUIRED";
3957
+ readonly BUNDLE_VERSION_VALID_FROM_INVALID: "BUNDLE_VERSION_VALID_FROM_INVALID";
3958
+ readonly BUNDLE_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS: "BUNDLE_VERSION_VALID_FROM_NOT_AFTER_PREVIOUS";
3959
+ readonly BUNDLE_VERSION_VALID_FROM_NOT_GAPLESS: "BUNDLE_VERSION_VALID_FROM_NOT_GAPLESS";
3960
+ readonly BUNDLE_VERSION_VALID_FROM_NOT_FUTURE: "BUNDLE_VERSION_VALID_FROM_NOT_FUTURE";
3961
+ readonly BUNDLE_VERSION_VALID_UNTIL_INVALID: "BUNDLE_VERSION_VALID_UNTIL_INVALID";
3962
+ readonly BUNDLE_VERSION_VALID_UNTIL_BEFORE_FROM: "BUNDLE_VERSION_VALID_UNTIL_BEFORE_FROM";
3963
+ /** Publish blocked by strict mode. Carries `warnings[]` with own codes. */
3964
+ readonly STRICT_MODE_VIOLATIONS: "STRICT_MODE_VIOLATIONS";
3965
+ readonly PLAN_NOT_FOUND: "PLAN_NOT_FOUND";
3966
+ readonly PLAN_VERSION_NOT_FOUND: "PLAN_VERSION_NOT_FOUND";
3967
+ readonly BUNDLE_NOT_FOUND: "BUNDLE_NOT_FOUND";
3968
+ readonly BUNDLE_VERSION_NOT_FOUND: "BUNDLE_VERSION_NOT_FOUND";
3969
+ readonly FEATURE_NOT_FOUND: "FEATURE_NOT_FOUND";
3970
+ readonly QUOTA_NOT_FOUND: "QUOTA_NOT_FOUND";
3971
+ readonly PROMOTION_NOT_FOUND: "PROMOTION_NOT_FOUND";
3972
+ readonly MARKETING_PROJECTION_NOT_FOUND: "MARKETING_PROJECTION_NOT_FOUND";
3973
+ readonly PLAN_ALREADY_EXISTS: "PLAN_ALREADY_EXISTS";
3974
+ readonly BUNDLE_ALREADY_EXISTS: "BUNDLE_ALREADY_EXISTS";
3975
+ readonly MARKETING_PROJECTION_ALREADY_EXISTS: "MARKETING_PROJECTION_ALREADY_EXISTS";
3976
+ /** A draft already exists — publish or discard it before creating another. */
3977
+ readonly PLAN_DRAFT_ALREADY_EXISTS: "PLAN_DRAFT_ALREADY_EXISTS";
3978
+ readonly BUNDLE_DRAFT_ALREADY_EXISTS: "BUNDLE_DRAFT_ALREADY_EXISTS";
3979
+ readonly QUOTA_NOT_IN_DISCOVERY_SNAPSHOT: "QUOTA_NOT_IN_DISCOVERY_SNAPSHOT";
3980
+ readonly DISCOVERY_STATUS_TRANSITION_INVALID: "DISCOVERY_STATUS_TRANSITION_INVALID";
3981
+ readonly DISCOVERY_NOT_INITIALIZED: "DISCOVERY_NOT_INITIALIZED";
3982
+ readonly PROMO_CODE_NOT_FOUND: "PROMO_CODE_NOT_FOUND";
3983
+ readonly PROMO_CODE_ALREADY_EXISTS: "PROMO_CODE_ALREADY_EXISTS";
3984
+ readonly PROMO_CODE_HAS_REDEMPTIONS: "PROMO_CODE_HAS_REDEMPTIONS";
3985
+ /** Not redeemable. Carries `reason` (`PromoPreviewInvalidReason`). */
3986
+ readonly PROMO_CODE_NOT_REDEEMABLE: "PROMO_CODE_NOT_REDEEMABLE";
3987
+ readonly PROMO_CODE_FORMAT_INVALID: "PROMO_CODE_FORMAT_INVALID";
3988
+ readonly PROMO_PERCENT_OUT_OF_RANGE: "PROMO_PERCENT_OUT_OF_RANGE";
3989
+ readonly PROMO_AMOUNT_NOT_POSITIVE: "PROMO_AMOUNT_NOT_POSITIVE";
3990
+ readonly PROMO_ONE_OFF_WITH_DURATION: "PROMO_ONE_OFF_WITH_DURATION";
3991
+ readonly PROMO_DURATION_INVALID: "PROMO_DURATION_INVALID";
3992
+ readonly PROMO_VALIDITY_WINDOW_INVALID: "PROMO_VALIDITY_WINDOW_INVALID";
3993
+ readonly PROMO_PLAN_NOT_DISCOUNTABLE: "PROMO_PLAN_NOT_DISCOUNTABLE";
3994
+ readonly PROMO_MIN_AMOUNT_NOT_POSITIVE: "PROMO_MIN_AMOUNT_NOT_POSITIVE";
3995
+ readonly PROMO_WOULD_PRODUCE_ZERO_INVOICE: "PROMO_WOULD_PRODUCE_ZERO_INVOICE";
3996
+ readonly PROMO_MAX_REDEMPTIONS_LOWERED: "PROMO_MAX_REDEMPTIONS_LOWERED";
3997
+ /** No authenticated user on the request. */
3998
+ readonly NOT_AUTHENTICATED: "NOT_AUTHENTICATED";
3999
+ /** Authenticated, but the request carries no tenant. */
4000
+ readonly NO_TENANT_ASSIGNED: "NO_TENANT_ASSIGNED";
4001
+ /** Neither `tenantId` nor `userId` could be resolved from the request. */
4002
+ readonly TENANT_CONTEXT_MISSING: "TENANT_CONTEXT_MISSING";
4003
+ readonly TENANT_ADMIN_REQUIRED: "TENANT_ADMIN_REQUIRED";
4004
+ readonly SUPER_ADMIN_REQUIRED: "SUPER_ADMIN_REQUIRED";
4005
+ /** TOTP MFA has never been set up for this user. */
4006
+ readonly MFA_NOT_SET_UP: "MFA_NOT_SET_UP";
4007
+ /** Endpoint is MFA-gated and no `X-Mfa-Code` header was sent. */
4008
+ readonly MFA_REQUIRED: "MFA_REQUIRED";
4009
+ /** The supplied TOTP code did not verify. */
4010
+ readonly MFA_FAILED: "MFA_FAILED";
4011
+ /** Module misconfiguration, not an end-user condition. */
4012
+ readonly AUTH_GUARDS_NOT_CONFIGURED: "AUTH_GUARDS_NOT_CONFIGURED";
4013
+ /** `SETUP_TOKEN` env not set → setup disabled. */
4014
+ readonly SETUP_DISABLED: "SETUP_DISABLED";
4015
+ /** Provided token does not match. */
4016
+ readonly INVALID_SETUP_TOKEN: "INVALID_SETUP_TOKEN";
4017
+ /** A SUPER_ADMIN already exists — self-disable. */
4018
+ readonly SETUP_ALREADY_DONE: "SETUP_ALREADY_DONE";
4019
+ /** Invalid email in the request. */
4020
+ readonly INVALID_EMAIL: "INVALID_EMAIL";
4021
+ /** Email already taken (mapped from `PlatformUserExistsError`). */
4022
+ readonly EMAIL_EXISTS: "EMAIL_EXISTS";
4023
+ };
4024
+ type PlatformErrorCode = SetupErrorCode | AuthErrorCode | PromoErrorCode | CatalogErrorCode | BillingErrorCode | ContractErrorCode | RegistrationErrorCode;
4025
+ /**
4026
+ * Shape of a coded error response.
4027
+ *
4028
+ * Note what NestJS does with this: throwing an exception with a string
4029
+ * argument yields `{ message, error, statusCode }`, whereas throwing it with
4030
+ * an object passes that object through verbatim. Coded errors therefore carry
4031
+ * no `error`/`statusCode` field in the body — the HTTP status is on the
4032
+ * response itself, which is where a client should read it.
4033
+ */
4034
+ interface PlatformErrorBody {
4035
+ /** Stable machine-readable discriminator. Resolve i18n by this. */
4036
+ code: PlatformErrorCode;
4037
+ /** English developer-facing fallback. Do not parse it. */
4038
+ message: string;
4039
+ /**
4040
+ * Named values interpolated into `message`, so a consumer can render a
4041
+ * translated sentence without scraping the ids back out of the text.
4042
+ */
4043
+ params?: Record<string, unknown>;
4044
+ }
4045
+
4046
+ /**
4047
+ * A user to be created already exists under this email. Adapters throw it
4048
+ * (e.g. in the SuperAdmin bootstrap); callers map it semantically:
4049
+ * SetupService → HTTP 409, CLI → readable message.
4050
+ */
4051
+ declare class PlatformUserExistsError extends Error {
4052
+ readonly email: string;
4053
+ readonly existingRole: PlatformRole;
4054
+ readonly code = "USER_ALREADY_EXISTS";
4055
+ constructor(email: string, existingRole: PlatformRole);
4056
+ }
4057
+ /**
4058
+ * Realm-safe type guard (checks `code` instead of `instanceof`) — works even
4059
+ * when thrower and catcher see the class from different module instances.
4060
+ */
4061
+ declare function isPlatformUserExistsError(err: unknown): err is PlatformUserExistsError;
4062
+
4063
+ /** Feature key → its `requires` keys (from Discovery/FeatureCatalogEntry). */
4064
+ type FeatureRequiresIndex = ReadonlyMap<string, readonly string[]>;
4065
+ interface FeatureRequiresSource {
4066
+ featureKey: string;
4067
+ requires?: readonly string[] | null;
4068
+ }
4069
+ /**
4070
+ * Builds the lookup index from snapshot features (`DiscoveredFeature`) or
4071
+ * catalog entries (`FeatureCatalogEntryRow`) — both carry
4072
+ * `featureKey` + `requires`. Self-references are ignored.
4073
+ */
4074
+ declare function buildFeatureRequiresIndex(features: readonly FeatureRequiresSource[]): FeatureRequiresIndex;
4075
+ /**
4076
+ * Union of the `requires` of all `selected` features minus the features that
4077
+ * `selected` itself contains — i.e. exactly the dependencies that must be
4078
+ * covered outside the selection. Sorted, deduplicated; an empty result
4079
+ * = the selection is self-contained (e.g. combo bundle SPORTPLATZ).
4080
+ */
4081
+ declare function collectUnsatisfiedRequires(selected: readonly string[], index: FeatureRequiresIndex): string[];
4082
+ /**
4083
+ * Bookability state of a bundle relative to the already covered
4084
+ * features (plan ∪ already selected/booked bundles):
4085
+ * - `covered` — all bundle features are already covered → would be sold
4086
+ * twice; the UI shows "already included" and it doesn't count.
4087
+ * - `missing-requires` — at least one `requiresFeatures` is uncovered → grey out.
4088
+ * - `bookable` — selectable.
4089
+ */
4090
+ type BundleAvailabilityState = 'bookable' | 'covered' | 'missing-requires';
4091
+ /** Feature carrier of a bundle for the bookability derivation. */
4092
+ interface BundleFeatureShape {
4093
+ features: readonly string[];
4094
+ requiresFeatures?: readonly string[] | null;
4095
+ }
4096
+ /**
4097
+ * Uncovered `requiresFeatures` of a bundle relative to the coverage. Sorted,
4098
+ * deduplicated; an empty result = all prerequisites covered.
4099
+ */
4100
+ declare function missingRequiresFor(bundle: BundleFeatureShape, coveredFeatures: ReadonlySet<string>): string[];
4101
+ /**
4102
+ * Unified status derivation for configurator and bundle-store UIs (#22/#35).
4103
+ * Order is deliberate: full coverage beats missing requires (a fully
4104
+ * covered bundle is never bookable, no matter which requires are open).
4105
+ * Quotas don't count — they act additively.
4106
+ */
4107
+ declare function resolveBundleAvailability(bundle: BundleFeatureShape, coveredFeatures: ReadonlySet<string>): BundleAvailabilityState;
4108
+ /** Bundle with features + identifying version ID for the redundancy derivation. */
4109
+ interface SelectableBundleShape extends BundleFeatureShape {
4110
+ bundleVersionId: string;
4111
+ /**
4112
+ * Optional sort position. Makes the choice of the kept bundle
4113
+ * predictable under mutual coverage (`selectChargeableBundles`).
4114
+ * Missing = sorted to the end, then by `bundleVersionId`.
4115
+ */
4116
+ sortOrder?: number;
4117
+ }
4118
+ /**
4119
+ * Coverage of a bundle relative to plan ∪ the *remaining* selected bundles —
4120
+ * the bundle itself doesn't count against itself (otherwise every bundle would
4121
+ * trivially be "already included"). Shared source for grid greying AND price/
4122
+ * payload exclusion: the configurator grid and the subscription draft must see
4123
+ * the same coverage, otherwise display and billing drift apart.
4124
+ */
4125
+ declare function coverageExcludingSelf(selfVersionId: string, planFeatures: readonly string[], selectedBundles: readonly SelectableBundleShape[]): Set<string>;
4126
+ /**
4127
+ * Is an already selected bundle fully covered (redundant) by plan ∪ the
4128
+ * remaining selected bundles? Such bundles get sold twice — they must
4129
+ * flow neither into the price total nor into the API payload.
4130
+ * Uses the same `resolveBundleAvailability` derivation as the grid.
4131
+ */
4132
+ declare function isBundleRedundant(bundle: SelectableBundleShape, planFeatures: readonly string[], selectedBundles: readonly SelectableBundleShape[]): boolean;
4133
+ /**
4134
+ * Minimally covering subset of the selected bundles: keeps exactly the bundles
4135
+ * that get charged/booked, and discards redundant ones (fully covered by
4136
+ * plan ∪ the remaining kept bundles).
4137
+ *
4138
+ * Iterative rather than one-shot removal: a single
4139
+ * `filter(b => !isBundleRedundant(b, plan, all))` over the full selection
4140
+ * discards ALL participants under mutual/cyclic coverage (Y={C} and
4141
+ * Z={C} cover each other → both filtered → feature C would be lost
4142
+ * and neither charged nor booked). Instead, ONE redundant bundle relative
4143
+ * to the CURRENTLY kept set is repeatedly removed and re-evaluated.
4144
+ * That guarantees that the kept set covers the same feature union
4145
+ * (minus plan) as the full selection — under mutual/cyclic coverage
4146
+ * exactly ONE bundle remains deterministically, and for a proper subset
4147
+ * (Y={C} ⊂ Z={C,D}) Y is discarded and Z is kept.
4148
+ */
4149
+ declare function selectChargeableBundles<T extends SelectableBundleShape>(planFeatures: readonly string[], selectedBundles: readonly T[]): T[];
4150
+
4151
+ /** Error code of the structured FeatureGuard 403 (#36). */
4152
+ declare const FEATURE_NOT_LICENSED: "FEATURE_NOT_LICENSED";
4153
+ /**
4154
+ * A purchase offer that would cover the missing feature — typically
4155
+ * a published catalog BundleVersion that contains the feature key.
4156
+ */
4157
+ interface UpsellOffer {
4158
+ bundleKey: string;
4159
+ /** Live BundleVersion ID — for the `add` request of the tenant self-service. */
4160
+ bundleVersionId?: string;
4161
+ /** Net monthly price; `null` = price only context-dependent (pricing override). */
4162
+ priceMonthlyNet: number | null;
4163
+ /** ISO 4217, e.g. `EUR`. */
4164
+ currency: string;
4165
+ label?: string;
4166
+ }
4167
+ /**
4168
+ * 403 body of the FeatureGuard on a missing entitlement — emitted in this
4169
+ * complete shape by every guard, `FeatureGuard` and `StaticFeatureGuard`
4170
+ * alike. Without a registered `UpsellOfferResolver` (or when the resolver
4171
+ * fails) `offers` is an empty array, never absent, so a consumer that matched
4172
+ * on `code === 'FEATURE_NOT_LICENSED'` may read `offers`/`featureKey` without
4173
+ * a presence check.
4174
+ */
4175
+ interface FeatureNotLicensedBody {
4176
+ code: typeof FEATURE_NOT_LICENSED;
4177
+ /** First required key — convenience for single-feature guards (issue shape). */
4178
+ featureKey: string;
4179
+ /** All required keys (`@RequireFeature` is a logical OR). */
4180
+ featureKeys: string[];
4181
+ offers: UpsellOffer[];
4182
+ /** Human-readable message (fallback display). */
4183
+ message: string;
4184
+ }
4185
+ /**
4186
+ * Port (#36): resolves missing feature keys into purchase offers. Consumers
4187
+ * register an implementation under `UPSELL_OFFER_RESOLVER_TOKEN`
4188
+ * (@saasicat/nest) — e.g. the bundled
4189
+ * `CatalogBundleUpsellResolver` against the published catalog bundles.
4190
+ *
4191
+ * `tenantId` allows tenant-specific offers (e.g. taking plan compatibility
4192
+ * or already-booked dependencies into account); the
4193
+ * default implementation does not use it.
4194
+ */
4195
+ interface UpsellOfferResolver {
4196
+ resolveOffers(featureKeys: string[], tenantId: string): Promise<UpsellOffer[]>;
4197
+ }
4198
+
4199
+ declare const PENDING_EMAIL_TTL_HOURS = 72;
4200
+ declare const PENDING_ONBOARDING_TTL_DAYS = 14;
4201
+ declare const PENDING_CHECKOUT_TTL_DAYS = 30;
4202
+ declare const OTP_TTL_MINUTES = 10;
4203
+ declare const PASSWORD_RESET_TTL_MINUTES = 30;
4204
+ /** Number of OTP sends per rolling window before further sends are silently swallowed. */
4205
+ declare const OTP_RATE_LIMIT_MAX_SENDS = 3;
4206
+ declare const OTP_RATE_LIMIT_WINDOW_MINUTES = 15;
4207
+ /**
4208
+ * Max. verification attempts per OTP code (each attempt atomically claims a
4209
+ * slot before the hash comparison). Once reached, `verifyOtp()` throws
4210
+ * `OTP_LOCKED` — even for a subsequently correct code. A newly generated OTP
4211
+ * resets the counter (sending stays rate-limited separately).
4212
+ * Env override: `SAAS_PLATFORM_OTP_VERIFY_MAX_ATTEMPTS`.
4213
+ */
4214
+ declare const OTP_VERIFY_MAX_ATTEMPTS = 5;
4215
+ type RegistrationStatus = 'PENDING_EMAIL_VERIFICATION' | 'EMAIL_VERIFIED' | 'PLAN_SELECTED' | 'CHECKOUT_STARTED' | 'EXPIRED' | 'DELETED';
4216
+ type RegistrationStep = 1 | 2 | 3 | 4;
4217
+ /** Mapping status -> step that the frontend uses after login/resume. */
4218
+ declare const REGISTRATION_STEP_BY_STATUS: Record<RegistrationStatus, RegistrationStep>;
4219
+ interface PendingRegistration {
4220
+ id: string;
4221
+ tenantName: string;
4222
+ tenantSlug: string | null;
4223
+ salutation: string | null;
4224
+ firstName: string;
4225
+ lastName: string;
4226
+ email: string;
4227
+ passwordHash: string;
4228
+ locale: string;
4229
+ status: RegistrationStatus;
4230
+ currentStep: RegistrationStep;
4231
+ emailVerifiedAt: Date | null;
4232
+ otpHash: string | null;
4233
+ otpExpiresAt: Date | null;
4234
+ otpSendCount: number;
4235
+ lastOtpSentAt: Date | null;
4236
+ /** Persistent counter of OTP verification attempts (brute-force lockout). */
4237
+ otpAttemptCount: number;
4238
+ selectedPlanId: string | null;
4239
+ /** Configurator selection snapshot (step 3). Set by the service. */
4240
+ configJson: RegistrationConfigSelection | null;
4241
+ /** Set on the first `saveConfiguration()` call. */
4242
+ billingCycle: 'MONTHLY' | 'YEARLY' | null;
4243
+ /** Plaintext code (UI display). Validation runs fresh every time. */
4244
+ appliedPromoCode: string | null;
4245
+ checkoutSessionId: string | null;
4246
+ checkoutStartedAt: Date | null;
4247
+ expiresAt: Date;
4248
+ createdAt: Date;
4249
+ updatedAt: Date;
4250
+ }
4251
+ interface PendingRegistrationCreateInput {
4252
+ tenantName: string;
4253
+ tenantSlug: string | null;
4254
+ salutation: string | null;
4255
+ firstName: string;
4256
+ lastName: string;
4257
+ email: string;
4258
+ passwordHash: string;
4259
+ locale: string;
4260
+ otpHash: string;
4261
+ otpExpiresAt: Date;
4262
+ expiresAt: Date;
4263
+ }
4264
+ interface PendingRegistrationUpdateInput {
4265
+ status?: RegistrationStatus;
4266
+ currentStep?: RegistrationStep;
4267
+ emailVerifiedAt?: Date | null;
4268
+ otpHash?: string | null;
4269
+ otpExpiresAt?: Date | null;
4270
+ otpSendCount?: number;
4271
+ lastOtpSentAt?: Date | null;
4272
+ otpAttemptCount?: number;
4273
+ selectedPlanId?: string | null;
4274
+ configJson?: RegistrationConfigSelection | null;
4275
+ billingCycle?: 'MONTHLY' | 'YEARLY' | null;
4276
+ appliedPromoCode?: string | null;
4277
+ checkoutSessionId?: string | null;
4278
+ checkoutStartedAt?: Date | null;
4279
+ expiresAt?: Date;
4280
+ }
4281
+ /** Adapter port: persistence for PendingRegistration (CRUD). */
4282
+ interface PendingRegistrationRepository {
4283
+ findById(id: string): Promise<PendingRegistration | null>;
4284
+ findByEmail(email: string): Promise<PendingRegistration | null>;
4285
+ /** Webhook lookup: finds the pending record for the provider session. */
4286
+ findByCheckoutSession(sessionId: string): Promise<PendingRegistration | null>;
4287
+ /**
4288
+ * Cleanup lookup: all pending records with `expiresAt < now`, max
4289
+ * `limit` entries per call (batch protection). Ordering irrelevant, the
4290
+ * cron service iterates sequentially.
4291
+ */
4292
+ findExpired(now: Date, limit: number): Promise<PendingRegistration[]>;
4293
+ create(input: PendingRegistrationCreateInput): Promise<PendingRegistration>;
4294
+ update(id: string, input: PendingRegistrationUpdateInput): Promise<PendingRegistration>;
4295
+ /**
4296
+ * Increments `otpAttemptCount` atomically by 1 and returns the NEW value.
4297
+ * Must be atomic on the DB side (e.g. Prisma `{ increment: 1 }`) so that
4298
+ * parallel failed attempts do not overwrite each other — the return
4299
+ * value is the authoritative threshold for the lockout check.
4300
+ */
4301
+ incrementOtpAttemptCount(id: string): Promise<number>;
4302
+ delete(id: string): Promise<void>;
4303
+ }
4304
+ /** Adapter port: detects whether a full user account (verified) exists for this email. */
4305
+ interface UserAccountLookup {
4306
+ hasActiveUser(email: string): Promise<boolean>;
4307
+ }
4308
+ /** Adapter port: check whether a slug is available for a new tenant. */
4309
+ interface SlugAvailabilityCheck {
4310
+ isSlugAvailable(slug: string): Promise<boolean>;
4311
+ }
4312
+ /** Wire format of a created checkout session (provider-agnostic). */
4313
+ interface CheckoutSession {
4314
+ /** Provider-specific session ID (e.g. Stripe `cs_…`). */
4315
+ sessionId: string;
4316
+ /** Payment URL to be opened by the frontend. */
4317
+ checkoutUrl: string;
4318
+ /** Optional: provider name (`stripe`, `dev-stub`) for logging/audit. */
4319
+ provider?: string;
4320
+ }
4321
+ type PaymentEventStatus = 'SUCCEEDED' | 'FAILED';
4322
+ /**
4323
+ * Adapter port: idempotency log for payment webhooks. Stripe (and most
4324
+ * other providers) deliver events at-least-once — the service calls
4325
+ * `tryClaim` as an atomic race guard BEFORE it triggers the final
4326
+ * activation.
4327
+ */
4328
+ interface PaymentEventLog {
4329
+ /**
4330
+ * Tries to insert an event record via `@unique` INSERT. Returns
4331
+ * `true` if it was newly created (webhook seen for the first time),
4332
+ * `false` if it already exists (duplicate → silently drop).
4333
+ *
4334
+ * Implementations must return a DB unique-constraint-violation error
4335
+ * (Prisma P2002) as `false`.
4336
+ */
4337
+ tryClaim(eventId: string, payload: {
4338
+ provider: string;
4339
+ sessionId: string | null;
4340
+ status: PaymentEventStatus;
4341
+ rawPayload?: unknown;
4342
+ }): Promise<boolean>;
4343
+ }
4344
+ interface FinalActivationResult {
4345
+ userId: string;
4346
+ tenantId: string;
4347
+ subscriptionId: string;
4348
+ }
4349
+ /**
4350
+ * Adapter port: orchestrates the final creation of User + Tenant +
4351
+ * Subscription after successful payment. App-specific — each app has its
4352
+ * own schema (e.g. Tenant + TenantUser + Role + UserRole +
4353
+ * Subscription).
4354
+ *
4355
+ * Implementations MUST perform the creation in a DB transaction so that
4356
+ * partial creations are fully rolled back on errors.
4357
+ */
4358
+ interface ActivationOrchestrator {
4359
+ activate(pending: PendingRegistration): Promise<FinalActivationResult>;
4360
+ }
4361
+ interface HandlePaymentEventInput {
4362
+ eventId: string;
4363
+ sessionId: string | null;
4364
+ provider: string;
4365
+ status: PaymentEventStatus;
4366
+ rawPayload?: unknown;
4367
+ }
4368
+ type HandlePaymentEventReason = 'ALREADY_PROCESSED' | 'PAYMENT_NOT_SUCCEEDED' | 'MISSING_SESSION_ID' | 'PENDING_REGISTRATION_NOT_FOUND' | 'INVALID_STATE';
4369
+ interface HandlePaymentEventResult {
4370
+ activated: boolean;
4371
+ reason?: HandlePaymentEventReason;
4372
+ result?: FinalActivationResult;
4373
+ }
4374
+ interface CleanupResult {
4375
+ /** Number of deleted PendingRegistration records. */
4376
+ deleted: number;
4377
+ /**
4378
+ * `true` if the batch limit was reached — the next cron run
4379
+ * handles the rest. Prevents memory spikes on large backlogs.
4380
+ */
4381
+ moreAvailable: boolean;
4382
+ }
4383
+ type RegistrationAuditEventType = 'REGISTRATION_STARTED' | 'REGISTRATION_NEUTRAL_ACTIVE_USER' | 'REGISTRATION_NEUTRAL_REPLAY' | 'REGISTRATION_NEUTRAL_EXPIRED' | 'OTP_VERIFIED' | 'OTP_VERIFY_FAILED' | 'OTP_RESEND_REQUESTED' | 'OTP_RATE_LIMIT_HIT' | 'PLAN_SELECTED' | 'CHECKOUT_STARTED' | 'PAYMENT_RECEIVED' | 'PAYMENT_DUPLICATE_IGNORED' | 'PAYMENT_FAILED' | 'ACTIVATION_COMPLETED' | 'LOGIN_SUCCEEDED' | 'LOGIN_INVALID_CREDENTIALS' | 'LOGIN_ONBOARDING_REQUIRED';
4384
+ /**
4385
+ * Context information that the audit layer records per event.
4386
+ * IP is expected as a hashed fingerprint — no plaintext IPs in the
4387
+ * audit log (GDPR/compliance), no plaintext email (account enumeration).
4388
+ */
4389
+ interface RegistrationAuditContext {
4390
+ ipHash?: string | null;
4391
+ userAgent?: string | null;
4392
+ }
4393
+ interface RegistrationAuditEvent {
4394
+ eventType: RegistrationAuditEventType;
4395
+ /**
4396
+ * Pending registration ID, if already known. `null` for neutral
4397
+ * responses (e.g. `start` without a new pending record).
4398
+ */
4399
+ pendingRegistrationId: string | null;
4400
+ context?: RegistrationAuditContext;
4401
+ /**
4402
+ * Free-form metadata field. NEVER put email/password/OTP in plaintext —
4403
+ * implementations must enforce that themselves.
4404
+ */
4405
+ metadata?: Record<string, unknown>;
4406
+ }
4407
+ /**
4408
+ * Adapter port: persists audit events for the registration flow.
4409
+ * Implementations typically target the app's respective `AuditLog` table.
4410
+ *
4411
+ * Log failures must not abort the auth flow — implementations should
4412
+ * catch errors internally and only log them, not throw.
4413
+ */
4414
+ interface RegistrationAuditLogger {
4415
+ log(event: RegistrationAuditEvent): Promise<void>;
4416
+ }
4417
+ interface ConfiguratorModel {
4418
+ id: string;
4419
+ code: string;
4420
+ name: string;
4421
+ glyph: string;
4422
+ tagline: string;
4423
+ /** Mapping to the PlanCatalog (STARTER/STANDARD/PROFESSIONAL). */
4424
+ planId: string;
4425
+ monthlyNet: number;
4426
+ yearlyNet: number;
4427
+ tags: string[];
4428
+ /** Feature keys included in the model price (PlanVersion.features). */
4429
+ includedFeatureKeys: string[];
4430
+ quotaBase: Record<string, number>;
4431
+ popular?: boolean;
4432
+ }
4433
+ interface ConfiguratorCatalog {
4434
+ /** Factor `yearlyNet = monthlyNet * cycleDiscount` (typically 10 = 2 months free). */
4435
+ cycleDiscount: number;
4436
+ currency: string;
4437
+ vatRate: number;
4438
+ models: ConfiguratorModel[];
4439
+ }
4440
+ interface RegistrationConfigSelection {
4441
+ modelId: string;
4442
+ billingCycle: 'MONTHLY' | 'YEARLY';
4443
+ appliedPromoCode: string | null;
4444
+ /**
4445
+ * P11.4: preselected CheckoutOffer from the website
4446
+ * (`?offer=<id>` parameter). Moves into `PendingRegistration.configJson`,
4447
+ * is read by the `ActivationOrchestrator` to consume the offer during
4448
+ * onboarding activation (`status=consumed`) and freeze it into
4449
+ * `Subscription.packageSnapshot`. When `null`, activation runs
4450
+ * without an offer snapshot.
4451
+ */
4452
+ offerId?: string | null;
4453
+ }
4454
+ interface ConfiguratorPriceBreakdown {
4455
+ cycle: 'MONTHLY' | 'YEARLY';
4456
+ effectiveQuotas: Record<string, number>;
4457
+ modelMonthlyNet: number;
4458
+ subtotalMonthlyNet: number;
4459
+ subtotalNet: number;
4460
+ discountAmount: number;
4461
+ totalNet: number;
4462
+ vatRate: number;
4463
+ totalGross: number;
4464
+ yearlySavings: number;
4465
+ appliedPromo?: {
4466
+ code: string;
4467
+ label: string;
4468
+ percent: number;
4469
+ };
4470
+ }
4471
+ interface SaveRegistrationConfigInput {
4472
+ pendingRegistrationId: string;
4473
+ selection: RegistrationConfigSelection;
4474
+ }
4475
+ interface SaveRegistrationConfigResult {
4476
+ pendingRegistrationId: string;
4477
+ status: RegistrationStatus;
4478
+ nextStep: RegistrationStep;
4479
+ selection: RegistrationConfigSelection;
4480
+ breakdown: ConfiguratorPriceBreakdown;
4481
+ }
4482
+ /**
4483
+ * Adapter port: provides the (app-specific) configurator catalog.
4484
+ *
4485
+ * Recommended implementation: `ConfiguratorCatalogBuilder` from
4486
+ * `@saasicat/nest/billing`. The builder combines SuperAdmin DB data
4487
+ * (live PlanVersions) with an app-local plan marketing map.
4488
+ */
4489
+ interface RegistrationConfiguratorLookup {
4490
+ getCatalog(): Promise<ConfiguratorCatalog>;
4491
+ }
4492
+ /**
4493
+ * Wire format of a live PlanVersion (latest published per planId).
4494
+ * Read from the DB table `plan_versions`.
4495
+ */
4496
+ interface ConfiguratorPlanVersionRow {
4497
+ planId: string;
4498
+ version: number;
4499
+ monthlyNet: number;
4500
+ yearlyNet: number;
4501
+ /** Feature keys included in the plan price. */
4502
+ features: string[];
4503
+ /** Quota key → base value (`-1` = unlimited). */
4504
+ quotas: Record<string, number>;
4505
+ marketed: boolean;
4506
+ }
4507
+ /**
4508
+ * Adapter port: reads the configurator sources from the DB (SuperAdmin
4509
+ * maintains them). Consumers typically use a Prisma implementation.
4510
+ */
4511
+ interface ConfiguratorSourcesLookup {
4512
+ listLivePlans(): Promise<ConfiguratorPlanVersionRow[]>;
4513
+ }
4514
+ /**
4515
+ * App-local marketing data for the plan selection area of the configurator.
4516
+ * The SuperAdmin stores only PlanId/prices — the "model" presentation
4517
+ * (display name, glyph, tagline) is branding and lives in the app.
4518
+ */
4519
+ interface ConfiguratorPlanMarketing {
4520
+ /** PlanId from the SuperAdmin (e.g. `STARTER`). */
4521
+ planId: string;
4522
+ code: string;
4523
+ name: string;
4524
+ glyph: string;
4525
+ tagline: string;
4526
+ tags: string[];
4527
+ popular?: boolean;
4528
+ }
4529
+ /**
4530
+ * Adapter port: provides the app-specific marketing data (plan names,
4531
+ * price parameters). Typically supplied by a static TS constant
4532
+ * in the app.
4533
+ */
4534
+ interface ConfiguratorMarketingProvider {
4535
+ listPlanMarketing(): ConfiguratorPlanMarketing[];
4536
+ /** Factor `yearlyNet = monthlyNet * cycleDiscount`. Default `10`. */
4537
+ getCycleDiscount(): number;
4538
+ getVatRate(): number;
4539
+ getCurrency(): string;
4540
+ }
4541
+ /**
4542
+ * Adapter port: promo-code preview against a gross subtotal.
4543
+ * Typically wraps `@saasicat/nest/promo:PromoCodesService.preview()`.
4544
+ */
4545
+ interface RegistrationPromoPreview {
4546
+ preview(params: {
4547
+ code: string;
4548
+ planId: string;
4549
+ billingCycle: 'MONTHLY' | 'YEARLY';
4550
+ subtotalGross: number;
4551
+ /** Optional for the firstTimeCustomersOnly check. */
4552
+ email?: string;
4553
+ }): Promise<{
4554
+ valid: boolean;
4555
+ reason?: string;
4556
+ percent?: number;
4557
+ label?: string;
4558
+ discountAmount?: number;
4559
+ }>;
4560
+ }
4561
+ /** Default TTL for signed resume tokens (60 min). */
4562
+ declare const REGISTRATION_RESUME_TTL_MINUTES = 60;
4563
+ /**
4564
+ * Adapter port: signs / verifies resume tokens for the
4565
+ * "resume registration" flow (cases C/D per spec).
4566
+ *
4567
+ * Token payload is a provider detail (typically JWT) — the service only knows
4568
+ * `pendingRegistrationId` as content and `ttlMinutes` as the expiry window.
4569
+ */
4570
+ interface RegistrationResumeTokenSigner {
4571
+ sign(params: {
4572
+ pendingRegistrationId: string;
4573
+ ttlMinutes?: number;
4574
+ }): Promise<string>;
4575
+ /**
4576
+ * Verifies the token. Throws if signature or expiry do not match
4577
+ * — the service layer translates this into a BadRequestException with
4578
+ * code `RESUME_TOKEN_INVALID`.
4579
+ */
4580
+ verify(token: string): Promise<{
4581
+ pendingRegistrationId: string;
4582
+ }>;
4583
+ }
4584
+ /** Adapter port: sends the resume-link email to the user. */
4585
+ interface RegistrationResumeDelivery {
4586
+ sendResumeEmail(params: {
4587
+ to: string;
4588
+ firstName: string;
4589
+ locale: string;
4590
+ resumeUrl: string;
4591
+ }): Promise<void>;
4592
+ }
4593
+ /**
4594
+ * Input to `PendingRegistrationService.resumeWithToken()` — the frontend
4595
+ * passes the token from the `?resume=<jwt>` query.
4596
+ */
4597
+ interface ResumeRegistrationInput {
4598
+ token: string;
4599
+ /**
4600
+ * Base URL of the app against which the resume link is generated
4601
+ * (e.g. `https://app.example.com`). Only forwarded to the mail delivery,
4602
+ * the service itself hosts no link.
4603
+ */
4604
+ resumeBaseUrl?: string;
4605
+ }
4606
+ /**
4607
+ * Public-safe snapshot of a PendingRegistration for the resume flow:
4608
+ * the frontend fills the completed onboarding steps with this data.
4609
+ *
4610
+ * Deliberately WITHOUT `passwordHash`, `otpHash`, `otpExpiresAt` — these must
4611
+ * never go to the client.
4612
+ */
4613
+ interface PendingRegistrationSnapshot {
4614
+ tenantName: string;
4615
+ tenantSlug: string | null;
4616
+ salutation: string | null;
4617
+ firstName: string;
4618
+ lastName: string;
4619
+ email: string;
4620
+ locale: string;
4621
+ status: RegistrationStatus;
4622
+ currentStep: RegistrationStep;
4623
+ emailVerifiedAt: string | null;
4624
+ selectedPlanId: string | null;
4625
+ /** Configurator selection snapshot for step-3 resume. */
4626
+ config: RegistrationConfigSelection | null;
4627
+ billingCycle: 'MONTHLY' | 'YEARLY' | null;
4628
+ appliedPromoCode: string | null;
4629
+ checkoutSessionId: string | null;
4630
+ }
4631
+ interface ResumeRegistrationResult {
4632
+ pendingRegistrationId: string;
4633
+ status: RegistrationStatus;
4634
+ nextStep: RegistrationStep;
4635
+ snapshot: PendingRegistrationSnapshot;
4636
+ }
4637
+ /** Adapter port: payment provider (Stripe, Dev-Stub, Mollie, ...). */
4638
+ interface PaymentProvider {
4639
+ /**
4640
+ * Creates a checkout session at the payment provider and returns the URL
4641
+ * that the frontend should redirect to.
4642
+ *
4643
+ * @param params.pendingRegistrationId Stored as `client_reference_id` (or similar)
4644
+ * in the provider — the webhook needs it to link back.
4645
+ * @param params.planId The chosen plan (Stripe price/product mapping lives
4646
+ * in the adapter).
4647
+ * @param params.successUrl Where to go after successful payment.
4648
+ * @param params.cancelUrl Where to go on cancellation.
4649
+ */
4650
+ createCheckoutSession(params: {
4651
+ pendingRegistrationId: string;
4652
+ planId: string;
4653
+ email: string;
4654
+ successUrl: string;
4655
+ cancelUrl: string;
4656
+ }): Promise<CheckoutSession>;
4657
+ }
4658
+ /** Adapter port: OTP delivery via email (or another channel). */
4659
+ interface RegistrationOtpDelivery {
4660
+ sendVerificationOtp(params: {
4661
+ to: string;
4662
+ code: string;
4663
+ firstName: string;
4664
+ locale: string;
4665
+ }): Promise<void>;
4666
+ }
4667
+ /** Wire format for a publicly selectable plan in onboarding step 3. */
4668
+ interface PublicSignupPlan {
4669
+ id: string;
4670
+ name?: string;
4671
+ tagline?: string;
4672
+ monthlyNet: number | null;
4673
+ yearlyNet: number | null;
4674
+ popular?: boolean;
4675
+ features: string[];
4676
+ }
4677
+ /**
4678
+ * Adapter port: provides the plan selection for step 3 (package selection).
4679
+ *
4680
+ * Implementation must check BOTH:
4681
+ * - Plan is marketable in the catalog (`marketed !== false`).
4682
+ * - A published, non-superseded `PlanVersion` exists in the DB
4683
+ * (otherwise the final `Subscription` cannot set a `planVersionId`).
4684
+ */
4685
+ interface PlanCatalogLookup {
4686
+ /** List of all public-signup-capable plans; empty if none. */
4687
+ listPublicSignupPlans(): Promise<PublicSignupPlan[]>;
4688
+ /** Detail lookup of a plan. null if not selectable (e.g. ENTERPRISE). */
4689
+ findPublicSignupPlan(planId: string): Promise<PublicSignupPlan | null>;
4690
+ }
4691
+ /** Input to PendingRegistrationService.start(). */
4692
+ interface StartRegistrationInput {
4693
+ tenantName: string;
4694
+ /** If null/undefined it is generated from tenantName. */
4695
+ tenantSlug?: string | null;
4696
+ salutation?: string | null;
4697
+ firstName: string;
4698
+ lastName: string;
4699
+ email: string;
4700
+ password: string;
4701
+ locale?: string;
4702
+ }
4703
+ /** Result contract for the (account-enumeration-safe) start call. */
4704
+ interface StartRegistrationResult {
4705
+ /** Always true. Account-enumeration protection: no information about DB state to the outside. */
4706
+ neutral: true;
4707
+ }
4708
+ interface VerifyRegistrationOtpResult {
4709
+ status: RegistrationStatus;
4710
+ nextStep: RegistrationStep;
4711
+ pendingRegistrationId: string;
4712
+ }
4713
+ interface SelectPlanInput {
4714
+ pendingRegistrationId: string;
4715
+ planId: string;
4716
+ }
4717
+ interface SelectPlanResult {
4718
+ pendingRegistrationId: string;
4719
+ status: RegistrationStatus;
4720
+ nextStep: RegistrationStep;
4721
+ selectedPlanId: string;
4722
+ }
4723
+ interface StartCheckoutInput {
4724
+ pendingRegistrationId: string;
4725
+ successUrl: string;
4726
+ cancelUrl: string;
4727
+ }
4728
+ interface StartCheckoutResult {
4729
+ pendingRegistrationId: string;
4730
+ status: RegistrationStatus;
4731
+ nextStep: RegistrationStep;
4732
+ checkoutSessionId: string;
4733
+ checkoutUrl: string;
4734
+ }
4735
+
4736
+ interface SetupStatusResponse {
4737
+ /** true as long as no SUPER_ADMIN exists — the UI then shows the wizard. */
4738
+ needsSetup: boolean;
4739
+ }
4740
+ interface SetupRequest {
4741
+ /** Must match the server-side `SETUP_TOKEN` (env var). */
4742
+ token: string;
4743
+ email: string;
4744
+ /** Optional — if absent, the server generates one and returns it. */
4745
+ password?: string;
4746
+ }
4747
+ interface SetupResult {
4748
+ userId: string;
4749
+ email: string;
4750
+ /** otpauth:// URI (fallback / deep link). */
4751
+ otpauthUri: string;
4752
+ /** Server-side rendered QR code as a PNG data URL for scanning. */
4753
+ qrDataUrl: string;
4754
+ /** Base32 TOTP secret as a fallback for manual entry. */
4755
+ secret: string;
4756
+ /** Set when the server generated the password (no `password` in the request). */
4757
+ generatedPassword?: string;
4758
+ }
4759
+ interface SetupConfirmMfaRequest {
4760
+ token: string;
4761
+ userId: string;
4762
+ /** TOTP code from the authenticator app. */
4763
+ code: string;
4764
+ }
4765
+ interface SetupConfirmMfaResponse {
4766
+ ok: boolean;
4767
+ }
4768
+
4769
+ /** Why a version is editable (for UI badges + audit logs). */
4770
+ type VersionEditableReason = 'draft' | 'pre-active';
4771
+ interface VersionEditability {
4772
+ editable: boolean;
4773
+ reason: VersionEditableReason | null;
4774
+ }
4775
+ /**
4776
+ * Decides whether a versioned catalog entity may currently be edited.
4777
+ * `now` is parameterized so that tests can be deterministic and
4778
+ * services use the same point in time for list annotation + mutation
4779
+ * gate.
4780
+ */
4781
+ declare function isVersionEditable(v: VersionedEntityBase, now?: Date): VersionEditability;
4782
+
4783
+ declare const ERROR_MESSAGES_EN: Record<PlatformErrorCode, string>;
4784
+ /** Values available for interpolation into a message template. */
4785
+ type ErrorMessageParams = Record<string, unknown>;
4786
+ /**
4787
+ * Replaces `{name}` with `params.name`. An unknown placeholder is left
4788
+ * verbatim, so a missing value is visible in the UI rather than silently
4789
+ * vanishing.
4790
+ */
4791
+ declare function formatErrorMessage(template: string, params?: ErrorMessageParams): string;
4792
+ /**
4793
+ * Turns an error body into display text.
4794
+ *
4795
+ * Falls back in this order: the consumer's own catalogue, the shipped default,
4796
+ * the English `message` the backend sent, and only then the bare code. Because
4797
+ * every coded exception carries a message, the last step is unreachable in
4798
+ * practice — a consumer that has not translated a new code yet shows English
4799
+ * prose, never `PLAN_VERSION_SUPERSEDED`.
4800
+ *
4801
+ * Interpolation reads `params` first and the remaining top-level body fields
4802
+ * second, so a template may name either without the value being duplicated on
4803
+ * the wire.
4804
+ */
4805
+ declare function resolveErrorMessage(body: Partial<PlatformErrorBody> & Record<string, unknown>, overrides?: Partial<Record<string, string>>, defaults?: Partial<Record<string, string>>): string;
4806
+
4807
+ declare const ERROR_MESSAGES_DE: Record<PlatformErrorCode, string>;
4808
+
4809
+ export { AUTH_ERROR_CODES, type ActionKey, type ActivationOrchestrator, type ActivePlanVersionWhere, type ActivePlanVersionWhereWithEndsAt, type ActiveVersionWhere, type ActiveVersionWhereWithEndsAt, type ActorTag, type AdminActor, type AdminAuditListFilter, type AdminManifest, type AdminResourcesPort, type AdminSubscriptionListRow, type AdminTenantDetail, type AdminTenantListFilter, type AdminTenantListRow, type AdminTenantStateResult, type AdminUserListFilter, type AdminUserListRow, type ApplyOnboardingSelectionInput, type ApplyOnboardingSelectionResult, type ApprovedCatalogKeys, type AuditActionDef, type AuditEntry, type AuditPort, type AuditQuery, type AuditQueryPort, type AuditStatsPort, type AuditStatsSnapshot, type AuthErrorCode, BILLING_ERROR_CODES, type BillingCycle, type BillingErrorCode, type BundleAvailabilityState, type BundleCompatibility, type BundleFeatureShape, type BundleListFilter, type BundlePricingOverride, type BundleRepository, type BundleRow, type BundleVersionFields, type BundleVersionMutationResult, type BundleVersionRow, CATALOG_ERROR_CODES, CONTRACT_ERROR_CODES, type CancelSubscriptionBundleData, type CapabilityCatalogEntryRow, type CapabilityCodeStatus, type CapabilityKey, type CapabilityKind, type CatalogEntryFilter, type CatalogEntryI18n, type CatalogEntryI18nFields, type CatalogEntryRepository, type CatalogErrorCode, type ChangeDirection, type CheckoutOfferFilter, type CheckoutOfferLineItem, type CheckoutOfferLineItemKind, type CheckoutOfferPriceBreakdown, type CheckoutOfferPromoCodeSnapshot, type CheckoutOfferPromotionSnapshot, type CheckoutOfferRepository, type CheckoutOfferRow, type CheckoutOfferStatus, type CheckoutSession, type CleanupResult, type CliUserRow, type ComponentKey, type ConfiguratorCatalog, type ConfiguratorMarketingProvider, type ConfiguratorModel, type ConfiguratorPlanMarketing, type ConfiguratorPlanVersionRow, type ConfiguratorPriceBreakdown, type ConfiguratorSourcesLookup, type ContractErrorCode, type ContractLineItemKind, type ContractLineItemRecord, type CreateBundleData, type CreateBundleVersionDraftData, type CreateCheckoutOfferData, type CreateMarketingProjectionData, type CreatePlanData, type CreatePlanVersionDraftData, type CreatePromoCodeData, type CreatePromoCodeRequest, type CreatePromotionData, type CreateSubscriptionBundleData, type CreateSubscriptionContractData, type CreateSuperAdminCliInput, type CreateTenantInput, type DiffResult, type DiscoveredCapability, type DiscoveredFeature, type DiscoveredQuota, type DiscoveredQuotaPolicy, type DiscoveryCodeStatus, type DiscoverySnapshot, type DiscoveryStatus, ERROR_MESSAGES_DE, ERROR_MESSAGES_EN, type EffectiveLimitsSnapshot, type ErrorMessageParams, FEATURE_NOT_LICENSED, type FeatureCatalogEntryRow, type FeatureDef, type FeatureKey, type FeatureNotLicensedBody, type FeatureRequiresIndex, type FeatureTier, type FeatureUiMeta, type FeatureUiRegistry, type FinalActivationResult, type FirstTimeCustomerCheck, type HandlePaymentEventInput, type HandlePaymentEventReason, type HandlePaymentEventResult, type ImmediatePlanChangeInput, type InvoiceLineItemSnapshot, type KpiCardDef, type KpiDisplayHint, type ManifestAccessPort, type ManifestContribution, type MarketingProjectionFilter, type MarketingProjectionRepository, type MarketingProjectionRow, type MarketingSettingsRepository, type MarketingSettingsRow, type MarketingTargetType, type MarketingTopFeature, type MfaPort, type NewContractLineItemData, OTP_RATE_LIMIT_MAX_SENDS, OTP_RATE_LIMIT_WINDOW_MINUTES, OTP_TTL_MINUTES, OTP_VERIFY_MAX_ATTEMPTS, type OnboardingPromoRedemption, type OnboardingSelectionRequest, type OnboardingSelectionResponse, PASSWORD_RESET_TTL_MINUTES, PENDING_CHECKOUT_TTL_DAYS, PENDING_EMAIL_TTL_HOURS, PENDING_ONBOARDING_TTL_DAYS, PLATFORM_ERROR_CODES, PROMO_ERROR_CODES, type Paginated, type PasswordHasher, type PasswordResetCliResult, type PaymentEventLog, type PaymentEventStatus, type PaymentProvider, type PendingRegistration, type PendingRegistrationCreateInput, type PendingRegistrationRepository, type PendingRegistrationSnapshot, type PendingRegistrationUpdateInput, type PersistenceCapabilities, PersistenceCapabilityError, type PersistenceClassRef, type PersistenceInjectionToken, type PersistenceProvider, type PlanCatalog, type PlanCatalogApp, type PlanCatalogImportReport, type PlanCatalogImportSink, type PlanCatalogLookup, type PlanCatalogMarketing, type PlanCatalogReadSink, type PlanCatalogReadSnapshot, type PlanDef, type PlanId, type PlanListFilter, type PlanRepository, type PlanRow, type PlanVersion, type PlanVersionFields, type PlanVersionMutationResult, type PlanVersionRecord, type PlanVersionRepository, type PlanVersionRow, type PlatformErrorBody, type PlatformErrorCode, type PlatformRole, type PlatformUserDto, PlatformUserExistsError, type ProjectPageDef, type PromoCode, type PromoCodeDurationType, type PromoCodeFilter, type PromoCodeRecord, type PromoCodeRedemption, type PromoCodeRedemptionListItem, type PromoCodeRedemptionRecord, type PromoCodeRedemptionRepository, type PromoCodeRedemptionStatus, type PromoCodeRepository, type PromoCodeStatsPort, type PromoCodeStatsSnapshot, type PromoCodeStatus, type PromoCodeValidationLog, type PromoCodeValidationLogRepository, type PromoCodeValidationResult, type PromoCodeValueType, type PromoErrorCode, type PromoPreviewInvalidReason, type PromoPreviewRequest, type PromoPreviewResponse, type PromoPreviewValidResponse, type PromoRevenueDeductionAggregator, type PromoSubscriptionLookup, type PromotionBillingCycle, type PromotionFilter, type PromotionI18n, type PromotionI18nFields, type PromotionRepository, type PromotionResult, type PromotionRow, type PromotionStatus, type PromotionTargetType, type PromotionType, type PromotionValue, type PublicBootResponse, type PublicComparisonRow, type PublicMarketingBundle, type PublicMarketingCatalogResponse, type PublicMarketingPlan, type PublicMarketingPromo, type PublicSignupPlan, type PublishBundleVersionData, type PublishPlanVersionData, type QuotaCatalogEntryRow, type QuotaEnforcementMode, type QuotaKey, type QuotaProvider, REGISTRATION_ERROR_CODES, REGISTRATION_RESUME_TTL_MINUTES, REGISTRATION_STEP_BY_STATUS, type ReassignTenantAdminCliResult, type RedeemPromoInTransactionCallback, type RegistrationAuditContext, type RegistrationAuditEvent, type RegistrationAuditEventType, type RegistrationAuditLogger, type RegistrationConfigSelection, type RegistrationConfiguratorLookup, type RegistrationErrorCode, type RegistrationOtpDelivery, type RegistrationPromoPreview, type RegistrationResumeDelivery, type RegistrationResumeTokenSigner, type RegistrationStatus, type RegistrationStep, type RequiredCapabilities, type ResumeRegistrationInput, type ResumeRegistrationResult, type ReviewCatalogEntryData, type RlsBypassPort, SETUP_ERROR_CODES, type SaaSiCatPersistenceAdapter, type SaaSiCatPersistenceAdminResources, type SaaSiCatPersistenceCatalog, type SaaSiCatPersistenceCore, type SaaSiCatPersistenceEntitlement, type SaaSiCatPersistencePromo, type SaaSiCatPersistenceTenantBilling, type SaveRegistrationConfigInput, type SaveRegistrationConfigResult, type ScheduledPlanChangeInput, type SelectPlanInput, type SelectPlanResult, type SelectableBundleShape, type SetCatalogEntryReviewData, type SetupConfirmMfaRequest, type SetupConfirmMfaResponse, type SetupErrorCode, type SetupRequest, type SetupResult, type SetupStatusResponse, type SlugAvailabilityCheck, type StandardPageDef, type StandardPageKey, type StartCheckoutInput, type StartCheckoutResult, type StartRegistrationInput, type StartRegistrationResult, type StrictModeWarning, type StrictModeWarningCode, type Subscription, type SubscriptionBundleRecord, type SubscriptionBundleRepository, type SubscriptionBundleView, type SubscriptionContractFilter, type SubscriptionContractInvoiceSnapshot, type SubscriptionContractPriceSnapshot, type SubscriptionContractRecord, type SubscriptionContractRepository, type SubscriptionContractStatus, type SubscriptionRecord, type SubscriptionRepository, type SubscriptionStatsPort, type SubscriptionStatsSnapshot, type SubscriptionStatus, type SubscriptionUsagePort, type SubscriptionUsageRecord, type SuperAdminProvisioningPort, type SyncDiscoveryResult, type TenantActionDef, type TenantColumnDef, type TenantDto, type TenantListFilter, type TenantPort, type TenantSubscriptionWritePort, type TerminateSubscriptionContractData, type TopPromoCode, type TransactionContext, type TransactionRunner, type UpdateBundleData, type UpdateBundleVersionDraftData, type UpdateCatalogEntryBaseData, type UpdateCatalogEntryI18nData, type UpdateCheckoutOfferData, type UpdateMarketingProjectionData, type UpdateMarketingSettingsData, type UpdatePlanData, type UpdatePlanVersionDraftData, type UpdatePromoCodeData, type UpdatePromoCodeRequest, type UpdatePromotionData, type UpsellOffer, type UpsellOfferResolver, type UpsertCapabilityEntryData, type UpsertFeatureCatalogEntryInput, type UpsertFeatureEntryData, type UpsertPlanInput, type UpsertPlanVersionInput, type UpsertQuotaEntryData, type UpsertResult, type UsageSnapshotPort, type UserAccountLookup, type UserListFilter, type UserManagementPort, type UserPort, type VerifyRegistrationOtpResult, type VersionChange, type VersionChangeDirection, type VersionEditability, type VersionEditableReason, type VersionedEntityBase, applyPromo, assertPersistenceCapabilities, buildActivePlanVersionWhere, buildActiveVersionWhere, buildFeatureRequiresIndex, classifyBundleVersionDiff, classifyPlanDiff, collectUnsatisfiedRequires, coverageExcludingSelf, formatErrorMessage, isBundleRedundant, isPlatformUserExistsError, isVersionEditable, missingRequiresFor, pickActivePromo, promoStatus, resolveBundleAvailability, resolveErrorMessage, selectChargeableBundles, startOfUtcDay };