@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.
- package/LICENSE +91 -0
- package/README.md +58 -0
- package/dist/.build-stamp +1 -0
- package/dist/index.cjs +957 -0
- package/dist/index.d.cts +4809 -0
- package/dist/index.d.ts +4809 -0
- package/dist/index.js +888 -0
- package/package.json +50 -0
package/dist/index.d.cts
ADDED
|
@@ -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 };
|