@saasicat/core 1.0.0-rc.2 → 1.0.0-rc.21

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/dist/index.d.cts CHANGED
@@ -1,5 +1,17 @@
1
1
  /** Start of day (00:00 UTC) of the moment — for day-inclusive date comparisons. */
2
2
  declare function startOfUtcDay(date: Date): Date;
3
+ /**
4
+ * The day before `value` — how a validity window closes when its successor
5
+ * opens (`validUntil = successor.validFrom − 1 day`, the rule this module's
6
+ * header states and `startOfUtcDay` above is the reading half of).
7
+ *
8
+ * It lived as three separate expressions before 2026-08-27: one in each
9
+ * adapter's publish path and one in the bundle repository, all agreeing by
10
+ * coincidence rather than by construction. Day arithmetic rather than
11
+ * `− 24 * 60 * 60 * 1000`: the subtraction is only equivalent while the value
12
+ * is a UTC midnight, and nothing in the type says it is.
13
+ */
14
+ declare function previousUtcDay(value: Date): Date;
3
15
  /** `<=` upper bound for a date (structurally Prisma-compatible). */
4
16
  interface DateAtOrBefore {
5
17
  lte: Date;
@@ -73,256 +85,6 @@ type ActiveVersionWhereWithEndsAt = ActivePlanVersionWhereWithEndsAt;
73
85
  */
74
86
  declare const buildActiveVersionWhere: typeof buildActivePlanVersionWhere;
75
87
 
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
88
  /**
327
89
  * Approval lifecycle of a feature or a quota. Approval happens per
328
90
  * FEATURE/QUOTA — not per capability (#20); only `approved` entries
@@ -385,7 +147,6 @@ type CatalogEntryI18n = Record<string, CatalogEntryI18nFields>;
385
147
  */
386
148
  interface CapabilityCatalogEntryRow {
387
149
  id: string;
388
- projectKey: string;
389
150
  capabilityKey: string;
390
151
  label: string;
391
152
  description: string | null;
@@ -422,7 +183,6 @@ type FeatureTier = 'CORE' | 'ADVANCED' | 'PRO' | 'ENTERPRISE' | string;
422
183
  */
423
184
  interface FeatureCatalogEntryRow {
424
185
  id: string;
425
- projectKey: string;
426
186
  featureKey: string;
427
187
  label: string;
428
188
  description: string | null;
@@ -477,7 +237,6 @@ interface FeatureCatalogEntryRow {
477
237
  */
478
238
  interface QuotaCatalogEntryRow {
479
239
  id: string;
480
- projectKey: string;
481
240
  quotaKey: string;
482
241
  label: string;
483
242
  description: string | null;
@@ -521,7 +280,6 @@ interface QuotaCatalogEntryRow {
521
280
  * matching field is relevant.
522
281
  */
523
282
  interface CatalogEntryFilter {
524
- projectKey: string;
525
283
  discoveryStatus?: DiscoveryStatus;
526
284
  codeStatus?: CapabilityCodeStatus;
527
285
  }
@@ -613,6 +371,18 @@ interface MarketingTopFeature {
613
371
  label: string;
614
372
  strong: string;
615
373
  }
374
+ /**
375
+ * The range a marketing projection's `priority` may take.
376
+ *
377
+ * Declared here rather than in the DTO because two sides need the same answer:
378
+ * the request pipe rejects anything outside it, and the admin UI computes
379
+ * priorities when an operator drags a plan into a new position. A UI that
380
+ * picked its own bounds would produce a value the pipe refuses — and it did,
381
+ * at the top of the range, where a list of tied plans was lifted past the
382
+ * maximum to pull the ties apart.
383
+ */
384
+ declare const MARKETING_PRIORITY_MIN = 0;
385
+ declare const MARKETING_PRIORITY_MAX = 10000;
616
386
  /**
617
387
  * Locale-specific marketing texts per plan/bundle version.
618
388
  * Read and projected by the Public-Catalog-Controller
@@ -623,7 +393,6 @@ interface MarketingTopFeature {
623
393
  */
624
394
  interface MarketingProjectionRow {
625
395
  id: string;
626
- projectKey: string;
627
396
  targetType: MarketingTargetType;
628
397
  targetVersionId: string;
629
398
  /** ISO-639-1, optionally with region suffix (`de`, `en`, `de-AT`). */
@@ -668,15 +437,13 @@ interface MarketingProjectionRow {
668
437
  createdAt: string;
669
438
  updatedAt: string;
670
439
  }
671
- /** Filter for `MarketingProjectionRepository.list()`. At least projectKey. */
440
+ /** Filter for `MarketingProjectionRepository.list()`. */
672
441
  interface MarketingProjectionFilter {
673
- projectKey: string;
674
442
  targetType?: MarketingTargetType;
675
443
  targetVersionId?: string;
676
444
  locale?: string;
677
445
  }
678
446
  interface CreateMarketingProjectionData {
679
- projectKey: string;
680
447
  targetType: MarketingTargetType;
681
448
  targetVersionId: string;
682
449
  locale?: string;
@@ -706,6 +473,409 @@ interface UpdateMarketingProjectionData {
706
473
  highlight?: boolean;
707
474
  }
708
475
 
476
+ /** The address lines a party is named with. Every one of them may be unknown. */
477
+ interface PartyAddress {
478
+ /** Street and number. */
479
+ addressLine1: string | null;
480
+ /** A second line, such as a building or a c/o. */
481
+ addressLine2: string | null;
482
+ postalCode: string | null;
483
+ city: string | null;
484
+ /** ISO 3166-1 alpha-2, upper case. */
485
+ country: string | null;
486
+ }
487
+ /**
488
+ * What names a legal entity: the name it is registered under and the
489
+ * identifiers its tax office gave it.
490
+ *
491
+ * These are the party a contract was concluded with. Under a running contract
492
+ * they change only as a correction of that same entity, recorded with the
493
+ * values they replaced and the reason; contact details change freely.
494
+ */
495
+ interface LegalIdentity {
496
+ /** The registered name, legal form included. */
497
+ legalName: string;
498
+ /** VAT identification number. */
499
+ vatId: string | null;
500
+ /** The national tax number, where one is stated beside or instead of the VAT id. */
501
+ taxNumber: string | null;
502
+ }
503
+ /** The fields of the legal identity, the ones a correction may change. */
504
+ type LegalIdentityField = keyof LegalIdentity;
505
+ /** Those fields, in the order they are named and shown. */
506
+ declare const LEGAL_IDENTITY_FIELDS: readonly LegalIdentityField[];
507
+ /** Whether two identities name the same entity: every field, value for value. */
508
+ declare function sameLegalIdentity(before: LegalIdentity, after: LegalIdentity): boolean;
509
+ /** The fields whose value differs between two identities, in field order. */
510
+ declare function movedIdentityFields(before: LegalIdentity, after: LegalIdentity): LegalIdentityField[];
511
+
512
+ /** The payment methods SaaSiCat takes, by the names `config/saas.yaml` uses. */
513
+ type PaymentMethodType = 'card' | 'sepa_debit';
514
+ /**
515
+ * Whom a payment method is set up for. The gateway hands it back unchanged
516
+ * with its confirmation, which is how the confirmation finds its way to the
517
+ * sign-up or the subscriber that asked for it.
518
+ */
519
+ type PaymentMethodSetupSubject = {
520
+ kind: 'registration';
521
+ pendingRegistrationId: string;
522
+ } | {
523
+ kind: 'subscriber';
524
+ subscriberId: string;
525
+ };
526
+ /** The party the gateway keeps the payment method for: its customer there. */
527
+ interface PaymentMethodHolder {
528
+ /** The legal name, which a direct debit mandate names. */
529
+ name: string;
530
+ /** Where the gateway sends what it sends; `null` lets its form ask. */
531
+ email: string | null;
532
+ address: PartyAddress;
533
+ /**
534
+ * The customer this party already has at the account, from an earlier
535
+ * payment method. `null` asks the gateway for a new one.
536
+ */
537
+ customerRef: string | null;
538
+ }
539
+ interface StartPaymentMethodSetupInput {
540
+ subject: PaymentMethodSetupSubject;
541
+ holder: PaymentMethodHolder;
542
+ /** What the form offers, from the account's `methods`. */
543
+ methods: readonly PaymentMethodType[];
544
+ /** Where the gateway sends the person once the form is done. */
545
+ successUrl: string;
546
+ /** Where the gateway sends the person who leaves the form. */
547
+ cancelUrl: string;
548
+ }
549
+ /** A body and headers exactly as they arrived, before anything parsed them. */
550
+ interface PaymentGatewayCallback {
551
+ body: string | Uint8Array;
552
+ headers: Readonly<Record<string, string | readonly string[] | undefined>>;
553
+ }
554
+ interface PaymentMethodSetupSession {
555
+ /**
556
+ * The gateway's identifier of the session, unique within its account — and
557
+ * an adapter that has no session of its own makes one per start rather than
558
+ * a constant. A session is confirmed once, which the event log holds, so a
559
+ * value two starts share lets the second confirmation through as the
560
+ * duplicate it is not.
561
+ */
562
+ sessionRef: string;
563
+ /** The gateway's form, where the person is sent next. */
564
+ redirectUrl: string;
565
+ /** The customer the payment method is set up for, created if none was given. */
566
+ customerRef: string;
567
+ /**
568
+ * The last moment a confirmation of this session can still arrive: the end
569
+ * of its form, after which nobody can complete it, plus the time the
570
+ * gateway goes on retrying a confirmation it could not deliver. `null` for
571
+ * a gateway that states neither. A sign-up's promo code slot is held until
572
+ * then, so a form paid shortly before its end still finds its slot when the
573
+ * confirmation arrives late, and an abandoned form gives it back.
574
+ */
575
+ confirmableUntil: Date | null;
576
+ /**
577
+ * A confirmation the gateway already holds when the session starts, to be
578
+ * handled like any callback. Only a gateway without a form of its own has
579
+ * one — the development gateway; a real gateway confirms through its
580
+ * webhook.
581
+ */
582
+ immediateCallback?: PaymentGatewayCallback;
583
+ }
584
+ /**
585
+ * What SaaSiCat keeps about a payment method: enough to show which one it is,
586
+ * never enough to pay with it (`SC-PRIV-005`).
587
+ */
588
+ interface MaskedPaymentMethod {
589
+ type: PaymentMethodType;
590
+ /** The card network, such as `visa`; `null` for a direct debit. */
591
+ brand: string | null;
592
+ /** The last four digits of the card number or the IBAN. */
593
+ last4: string;
594
+ /** 1–12; `null` for a direct debit. */
595
+ expiryMonth: number | null;
596
+ /** Four digits; `null` for a direct debit. */
597
+ expiryYear: number | null;
598
+ /** ISO 3166-1 alpha-2 of the card's issuer or the bank account, where the gateway says. */
599
+ country: string | null;
600
+ /** The bank code of a direct debit account, where the gateway says. */
601
+ bankCode: string | null;
602
+ /** The reference of a direct debit mandate, which a debit announcement quotes. */
603
+ mandateReference: string | null;
604
+ }
605
+ /** A payment method the gateway confirmed, with the references that reach it there. */
606
+ interface ConfirmedPaymentMethod extends MaskedPaymentMethod {
607
+ customerRef: string;
608
+ paymentMethodRef: string;
609
+ }
610
+ /** A gateway callback, verified and translated. */
611
+ type PaymentGatewayEvent = {
612
+ kind: 'payment-method-confirmed';
613
+ /** The gateway's identifier of the event, unique within its account. */
614
+ eventId: string;
615
+ /** When the gateway says it happened. */
616
+ occurredAt: Date;
617
+ sessionRef: string;
618
+ subject: PaymentMethodSetupSubject;
619
+ paymentMethod: ConfirmedPaymentMethod;
620
+ } | {
621
+ kind: 'payment-method-setup-failed';
622
+ eventId: string;
623
+ occurredAt: Date;
624
+ sessionRef: string;
625
+ subject: PaymentMethodSetupSubject;
626
+ } | {
627
+ /** Genuine, and nothing SaaSiCat acts on. */
628
+ kind: 'unhandled';
629
+ eventId: string;
630
+ occurredAt: Date;
631
+ /** The gateway's own name for the event, for the log. */
632
+ type: string;
633
+ };
634
+ /**
635
+ * One account at a payment gateway: its keys, its form and its callbacks.
636
+ *
637
+ * An adapter is bound to one account, and `config/saas.yaml#payments.accounts`
638
+ * names it. Mollie or any other provider is another adapter behind this port.
639
+ */
640
+ interface PaymentGateway {
641
+ /** The provider, as `config/saas.yaml` names it, e.g. `stripe`. */
642
+ readonly provider: string;
643
+ /**
644
+ * Opens the gateway's form for a payment method. Nothing is confirmed until
645
+ * the gateway says so through `readCallback`.
646
+ */
647
+ startPaymentMethodSetup(input: StartPaymentMethodSetupInput): Promise<PaymentMethodSetupSession>;
648
+ /**
649
+ * Verifies a callback with the account's secret and translates it.
650
+ * Throws `PaymentCallbackRejectedError` for anything the gateway did not
651
+ * send, before a single field of it is trusted.
652
+ */
653
+ readCallback(callback: PaymentGatewayCallback): Promise<PaymentGatewayEvent>;
654
+ }
655
+ /**
656
+ * A callback that is not the gateway's: a missing or wrong signature, a stale
657
+ * timestamp, a body that was altered. Nothing is read from it.
658
+ */
659
+ declare class PaymentCallbackRejectedError extends Error {
660
+ readonly code = "PAYMENT_CALLBACK_REJECTED";
661
+ constructor(reason: string);
662
+ }
663
+ /** Realm-safe type guard, like `isPlatformUserExistsError`. */
664
+ declare function isPaymentCallbackRejectedError(err: unknown): err is PaymentCallbackRejectedError;
665
+
666
+ type FeatureKey = string;
667
+ type PlanId = string;
668
+ type QuotaKey = string;
669
+ interface FeatureDef {
670
+ key: FeatureKey;
671
+ label?: string;
672
+ icon?: string;
673
+ /** CORE / ADVANCED / PRO / BUSINESS / ENTERPRISE_ONLY — convention. */
674
+ tier?: string;
675
+ plannedOnly?: boolean;
676
+ }
677
+ interface PlanDef {
678
+ id: PlanId;
679
+ name?: string;
680
+ tagline?: string;
681
+ /** false = not selectable in self-service onboarding. Default: true. */
682
+ marketed?: boolean;
683
+ /** Highlighted card in onboarding (max. 1 per catalog). */
684
+ popular?: boolean;
685
+ /** Net monthly price. null = on request. */
686
+ monthlyNet?: number | null;
687
+ /** Net total amount per year. null = monthly only. */
688
+ yearlyNet?: number | null;
689
+ /** Map quotaKey → max value. -1 = unlimited. */
690
+ quotas: Record<QuotaKey, number>;
691
+ features: FeatureKey[];
692
+ }
693
+ /** App-wide marketing configuration. */
694
+ interface PlanCatalogMarketing {
695
+ /**
696
+ * Allowed language pool that the app may market. First = default
697
+ * locale. From it, the SuperAdmin activates a subset in the marketing
698
+ * catalog (LocaleManager).
699
+ */
700
+ availableLocales: string[];
701
+ }
702
+ /**
703
+ * App identity block for branding + version. Consumed by the `AdminPublicBootController`
704
+ * and the `AdminManifestConfigFactory`; the SuperAdmin UI (platform
705
+ * LoginPage, AdminLayout brand block) reads the same fields via PublicBoot.
706
+ *
707
+ * `name` = brand display name (e.g. "DemoApp", "ClubApp").
708
+ * `label` = tag/subtitle in the brand block (e.g. "SuperAdmin").
709
+ * `version` = app version string (build info).
710
+ * `icon` = 2-character abbreviation for the logo badge (e.g. "ma", "da").
711
+ * `logoUrl` = optional URL to a PNG/SVG; if set, the UI renders an <img>
712
+ * instead of the initials badge.
713
+ */
714
+ interface PlanCatalogApp {
715
+ name: string;
716
+ label?: string;
717
+ version?: string;
718
+ icon?: string;
719
+ logoUrl?: string;
720
+ }
721
+ /**
722
+ * Notice periods, one per rhythm.
723
+ *
724
+ * One number for both was the shape until 2026-08-27, and it could not be right
725
+ * for both: a yearly contract with a fortnight of notice is unusual, and a
726
+ * monthly contract with three months of notice is void against a consumer. The
727
+ * two are configured apart because real contracts set them apart.
728
+ *
729
+ * Both members are required. A missing rhythm would read as zero, and a silent
730
+ * zero is a commercial decision nobody made — the same defect one level below
731
+ * the one that moved these settings into the file.
732
+ *
733
+ * **No ceiling is enforced.** §309 Nr. 9 BGB limits the notice period in German
734
+ * consumer contracts to one month, and an installation serving businesses is
735
+ * not bound by it. The platform cannot know which it is, so the number is the
736
+ * consumer app's to choose and this is the sentence that says what it costs.
737
+ */
738
+ interface CancellationNoticePeriods {
739
+ /** Days of notice for a monthly subscription. */
740
+ monthly: number;
741
+ /** Days of notice for a yearly subscription. */
742
+ yearly: number;
743
+ }
744
+ /**
745
+ * Plans a tenant may not reach or leave without talking to sales.
746
+ *
747
+ * `asTarget`: may not be selected via self-service — typically ENTERPRISE,
748
+ * which only a special contract activates. `asSource`: may not be left via
749
+ * self-service — typically an active special contract.
750
+ *
751
+ * Both lists are required and may be empty. An empty list says out loud that
752
+ * self-service reaches every plan, which is a decision rather than an omission.
753
+ */
754
+ interface SelfServiceBlockedPlans {
755
+ asTarget: string[];
756
+ asSource: string[];
757
+ }
758
+ /**
759
+ * Commercial settings for the tenant-facing self-service routes.
760
+ *
761
+ * They live in `config/saas.yaml` and nowhere else: an operator reading the
762
+ * file has to be reading the values that are running, with no "unless somebody
763
+ * passed it in code" attached. The file is read at boot, so an edit lands on
764
+ * the next restart.
765
+ */
766
+ interface PlanCatalogTenantBilling {
767
+ cancellationNoticeDays: CancellationNoticePeriods;
768
+ selfServiceBlockedPlans: SelfServiceBlockedPlans;
769
+ }
770
+ /**
771
+ * Who is told when the settings in the file change between two starts.
772
+ *
773
+ * The record inside the application is written whether or not anybody is
774
+ * named here; mail is the addition, never the substitute. Mailed only where an
775
+ * email port is bound — without one the boot log says so once, and the change
776
+ * is recorded in the application only.
777
+ */
778
+ interface PlanCatalogNotifications {
779
+ /** Addresses mailed when a start finds the applied settings changed. */
780
+ settingsChanged?: string[];
781
+ }
782
+ /**
783
+ * The legal entity on the operator's side of every contract the installation
784
+ * concludes. A contract copies it on the day it is concluded; only the legal
785
+ * name is required while nothing is invoiced.
786
+ */
787
+ interface PlanCatalogIssuer {
788
+ legalName: string;
789
+ addressLine1?: string;
790
+ addressLine2?: string;
791
+ postalCode?: string;
792
+ city?: string;
793
+ /** ISO 3166-1 alpha-2. */
794
+ country?: string;
795
+ vatId?: string;
796
+ taxNumber?: string;
797
+ /** Declares a changed identity as a correction of the same legal entity. */
798
+ correctionOf?: PlanCatalogIssuerCorrection;
799
+ }
800
+ /**
801
+ * What a changed issuer identity replaces, and why.
802
+ *
803
+ * The identity is the counterparty a contract names, so it moves under a
804
+ * running contract only as a correction of that same entity. Each field names
805
+ * the value the installation recorded before the change, `null` where it
806
+ * recorded none; a field the change leaves alone need not be named.
807
+ */
808
+ interface PlanCatalogIssuerCorrection {
809
+ legalName?: string | null;
810
+ vatId?: string | null;
811
+ taxNumber?: string | null;
812
+ /** Why the same entity now reads differently. Kept in the settings record. */
813
+ reason: string;
814
+ }
815
+ /** How the parties contracts are concluded with are numbered. */
816
+ interface PlanCatalogSubscribers {
817
+ /**
818
+ * Put in front of every customer number assigned from the next start on.
819
+ * A number keeps the prefix it was assigned with.
820
+ */
821
+ customerNumberPrefix?: string;
822
+ }
823
+ /** One account at a payment gateway, by the name `PlanCatalogPayments.accounts` gives it. */
824
+ interface PlanCatalogPaymentAccount {
825
+ /** The provider its bound adapter names itself as, e.g. `stripe`. */
826
+ provider: string;
827
+ /** What a new payment method may be at this account. Read for `newPaymentMethods` only. */
828
+ methods?: PaymentMethodType[];
829
+ }
830
+ /**
831
+ * The gateway accounts payment methods are taken through. Their keys are bound
832
+ * in code from the environment, never written into the file.
833
+ */
834
+ interface PlanCatalogPayments {
835
+ /** The account a new payment method is taken at. Omitted, none is taken. */
836
+ newPaymentMethods?: string;
837
+ /** The origins a gateway's form may send a person back to, such as `https://app.example.com`. */
838
+ returnUrlOrigins: string[];
839
+ /** Every account taking new payment methods or holding a reference in use. */
840
+ accounts: Record<string, PlanCatalogPaymentAccount>;
841
+ }
842
+ /**
843
+ * The part of `config/saas.yaml` that is configuration rather than catalogue.
844
+ *
845
+ * Declared apart so it can be handed on whole — a database catalogue takes its
846
+ * settings from the file and its plans from the database — without anybody
847
+ * listing the blocks again. `CATALOGUE_KEYS` names what is left over.
848
+ */
849
+ interface PlanCatalogSettings {
850
+ /** App identity (branding + version), see PlanCatalogApp. */
851
+ app: PlanCatalogApp;
852
+ /** ISO-4217 currency code. */
853
+ currency: string;
854
+ /** VAT rate in percent. */
855
+ vatRate: number;
856
+ /** Commercial settings for the tenant self-service routes. */
857
+ tenantBilling: PlanCatalogTenantBilling;
858
+ /** App-wide marketing configuration. Optional. */
859
+ marketing?: PlanCatalogMarketing;
860
+ /** Who is told when the settings change between two starts. Optional. */
861
+ notifications?: PlanCatalogNotifications;
862
+ /** The operator's side of every contract. Optional until invoicing requires it. */
863
+ issuer?: PlanCatalogIssuer;
864
+ /** How subscribers are numbered. Optional. */
865
+ subscribers?: PlanCatalogSubscribers;
866
+ /** The payment gateway accounts. Optional until payment methods are taken. */
867
+ payments?: PlanCatalogPayments;
868
+ }
869
+ interface PlanCatalog extends PlanCatalogSettings {
870
+ schemaVersion: 1;
871
+ features?: FeatureDef[];
872
+ /**
873
+ * Optional. When omitted, plans come exclusively from the
874
+ * AdminUI / DB table (Plans/PlanVersions lifecycle).
875
+ */
876
+ plans?: PlanDef[];
877
+ }
878
+
709
879
  type PromoCodeValueType = 'PERCENT' | 'ABSOLUTE';
710
880
  type PromoCodeDurationType = 'ONCE' | 'MONTHS' | 'BILLING_CYCLES';
711
881
  type PromoCodeStatus = 'ACTIVE' | 'PAUSED' | 'EXHAUSTED' | 'EXPIRED';
@@ -754,6 +924,11 @@ interface PromoCode {
754
924
  validUntil: string | null;
755
925
  maxRedemptions: number | null;
756
926
  redemptionsCount: number;
927
+ /**
928
+ * Slots held for checkouts that started and have not concluded. A code has
929
+ * a free slot while `redemptionsCount + heldCount` is below `maxRedemptions`.
930
+ */
931
+ heldCount: number;
757
932
  appliesToPlans: PlanId[];
758
933
  appliesToBilling: BillingCycle | null;
759
934
  firstTimeCustomersOnly: boolean;
@@ -902,7 +1077,7 @@ interface VersionedEntityBase {
902
1077
  * own migration).
903
1078
  * - `startedAt` is the contract start of this booking.
904
1079
  * - `minimumTermEndsAt` = end of the minimum term; `null` = no minimum term
905
- * (platform default = 12 months, set service-side).
1080
+ * (platform default = no commitment, set service-side).
906
1081
  * - `canceledAt` / `canceledEffectiveAt`: cancellation anchor vs. effective
907
1082
  * date. Before the minimum term ends, `canceledEffectiveAt =
908
1083
  * minimumTermEndsAt`, otherwise the subscription's period end.
@@ -919,6 +1094,18 @@ interface SubscriptionBundleRecord {
919
1094
  minimumTermEndsAt: Date | null;
920
1095
  canceledAt: Date | null;
921
1096
  canceledEffectiveAt: Date | null;
1097
+ /**
1098
+ * The rhythm this booking is billed in, and the window it is billed for.
1099
+ *
1100
+ * A bundle's periods end on the day its plan's do — the first one short,
1101
+ * from the booking to the next occurrence of that day, and every one after
1102
+ * it anchor to anchor. Null on a booking made before these fields existed,
1103
+ * or on one whose plan has no period; readers fall back to the plan's
1104
+ * cycle, which is what every booking used before.
1105
+ */
1106
+ billingCycle: string | null;
1107
+ currentPeriodStart: Date | null;
1108
+ currentPeriodEnd: Date | null;
922
1109
  createdAt: Date;
923
1110
  updatedAt: Date;
924
1111
  }
@@ -932,14 +1119,27 @@ interface SubscriptionBundleRecord {
932
1119
  interface SubscriptionBundleView extends SubscriptionBundleRecord {
933
1120
  bundleKey: string | null;
934
1121
  label: string | null;
935
- monthlyNet: string | null;
1122
+ /**
1123
+ * What this booking is billed at, in the rhythm it was booked in and with
1124
+ * the plan's pricing override applied.
1125
+ *
1126
+ * It was `monthlyNet` until 2026-08-27 and carried the bundle's base
1127
+ * monthly price whatever the booking was — so a yearly booking of a bundle
1128
+ * priced 10 monthly and 100 yearly reported 10. The name was half the
1129
+ * defect: a field called `monthlyNet` on a yearly booking cannot be right.
1130
+ */
1131
+ priceNet: number | null;
936
1132
  }
937
1133
  interface CreateSubscriptionBundleData {
938
1134
  subscriptionId: string;
939
1135
  bundleVersionId: string;
940
1136
  startedAt: Date;
941
- /** Default = startedAt + 12 months, unless set. */
1137
+ /** Null unless a commitment was configured or asked for. */
942
1138
  minimumTermEndsAt?: Date | null;
1139
+ /** The rhythm and window worked out above this port. */
1140
+ billingCycle?: string | null;
1141
+ currentPeriodStart?: Date | null;
1142
+ currentPeriodEnd?: Date | null;
943
1143
  }
944
1144
  interface CancelSubscriptionBundleData {
945
1145
  canceledAt: Date;
@@ -988,7 +1188,6 @@ interface BundlePricingOverride {
988
1188
  */
989
1189
  interface BundleRow {
990
1190
  id: string;
991
- projectKey: string;
992
1191
  bundleKey: string;
993
1192
  label: string;
994
1193
  description: string | null;
@@ -1027,7 +1226,6 @@ interface BundleVersionRow extends VersionedEntityBase {
1027
1226
  * first BundleVersion via `CreateBundleVersionDraftData`.
1028
1227
  */
1029
1228
  interface CreateBundleData {
1030
- projectKey: string;
1031
1229
  bundleKey: string;
1032
1230
  label: string;
1033
1231
  description?: string | null;
@@ -1036,9 +1234,9 @@ interface CreateBundleData {
1036
1234
  i18n?: CatalogEntryI18n;
1037
1235
  }
1038
1236
  /**
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.
1237
+ * Fields that may be changed on the bundle master. `bundleKey` is
1238
+ * intentionally not here — master identity is immutable; whoever wants to
1239
+ * change it creates a new bundle and retires the old one.
1042
1240
  */
1043
1241
  interface UpdateBundleData {
1044
1242
  label?: string;
@@ -1143,34 +1341,291 @@ interface PublishBundleVersionData {
1143
1341
  validUntil?: string | null;
1144
1342
  }
1145
1343
  /**
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.
1344
+ * Code of a strict-mode violation. lists the eight rules
1345
+ * that are checked; each rule has its own code so the UI can show
1346
+ * focused help texts.
1347
+ */
1348
+ 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';
1349
+ /**
1350
+ * A strict-mode violation. `field` points to the violating field
1351
+ * (e.g. `'features[3]'`), `value` is the concrete value (e.g. `'INVENTORY'`).
1352
+ */
1353
+ interface StrictModeWarning {
1354
+ code: StrictModeWarningCode;
1355
+ /** Human-readable reason (German). */
1356
+ message: string;
1357
+ /** Path to the violating field; optional. */
1358
+ field?: string;
1359
+ /** The concrete violating value; optional. */
1360
+ value?: string;
1361
+ }
1362
+ /**
1363
+ * Service result for mutating Bundle operations
1364
+ * (createDraft, updateDraft, publish): returns the persisted row plus
1365
+ * a list of strict-mode warnings. In `warn-only` mode the
1366
+ * warnings go into the UI as a banner; in `blocking` mode the service throws
1367
+ * HTTP 422 instead, with the same warning list as the body.
1368
+ */
1369
+ interface BundleVersionMutationResult {
1370
+ bundleVersion: BundleVersionRow;
1371
+ warnings: StrictModeWarning[];
1372
+ }
1373
+
1374
+ /**
1375
+ * The column values a new BundleVersion draft starts from.
1376
+ *
1377
+ * Every adapter has to apply the same defaults — an absent quota map is `{}`,
1378
+ * an absent price is null rather than zero, an unstated `marketed` is true —
1379
+ * and two adapters spelling that out separately is the same decision written
1380
+ * twice. It is also the variant jscpd does catch, which is how this came out:
1381
+ * `adapter-drizzle` learning about bundles put a second copy beside
1382
+ * `adapter-prisma`'s.
1383
+ *
1384
+ * Validity windows are deliberately absent. Whether a draft carries
1385
+ * `validFrom`/`validUntil` is an adapter capability rather than a default, and
1386
+ * an adapter that does not maintain those columns must not write them.
1387
+ */
1388
+ declare function bundleDraftDefaults(data: CreateBundleVersionDraftData): {
1389
+ baseVersionId: string | null;
1390
+ features: string[];
1391
+ quotas: Record<string, number>;
1392
+ compatibility: Record<string, unknown>;
1393
+ pricingOverrides: unknown[];
1394
+ monthlyNet: string | null;
1395
+ yearlyNet: string | null;
1396
+ marketed: boolean;
1397
+ changeNote: string;
1398
+ createdByUserId: string | null;
1399
+ };
1400
+ /**
1401
+ * The column values a new Bundle stem starts from.
1402
+ *
1403
+ * The same defaulting rule as above, one level up: an absent description or
1404
+ * icon is null rather than an empty string, an unstated sort order is 0, an
1405
+ * absent translation map is `{}`. Written out in five places before this — two
1406
+ * adapters and two fakes — which is four opportunities for one of them to
1407
+ * decide differently.
1408
+ */
1409
+ declare function bundleStemDefaults(data: CreateBundleData): {
1410
+ bundleKey: string;
1411
+ label: string;
1412
+ description: string | null;
1413
+ icon: string | null;
1414
+ sortOrder: number;
1415
+ i18n: CatalogEntryI18n;
1416
+ };
1417
+ /** The stored shape both adapters read a bundle stem back from. */
1418
+ interface StoredBundleStem {
1419
+ id: string;
1420
+ bundleKey: string;
1421
+ label: string;
1422
+ description: string | null;
1423
+ icon: string | null;
1424
+ sortOrder: number;
1425
+ i18n: unknown;
1426
+ createdAt: Date;
1427
+ updatedAt: Date;
1428
+ deletedAt: Date | null;
1429
+ }
1430
+ /**
1431
+ * A stored bundle stem as the port describes it.
1432
+ *
1433
+ * The two stores spell the columns identically, so the mapping was identical
1434
+ * too — and an identical mapping in two files is one place for a field to be
1435
+ * forgotten when the row grows. `i18n` arrives as JSON of unknown shape from
1436
+ * both, and a non-object becomes `{}` rather than reaching a caller that
1437
+ * expects a map.
1149
1438
  */
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';
1439
+ declare function toBundleStemRow(row: StoredBundleStem): BundleRow;
1151
1440
  /**
1152
- * A strict-mode violation. `field` points to the violating field
1153
- * (e.g. `'features[3]'`), `value` is the concrete value (e.g. `'INVENTORY'`).
1441
+ * The fields a caller actually gave, as a patch.
1442
+ *
1443
+ * The update DTOs in this codebase mean three different things by three
1444
+ * different values: a value changes the column, an explicit `null` clears it,
1445
+ * and an **omitted** field leaves it alone. Only the last one needs care, and
1446
+ * it was written out as `...(data.x !== undefined ? { x: data.x } : {})` more
1447
+ * than fifty times across five repositories — one decision, fifty
1448
+ * opportunities to spell it differently, and the duplication ratchet is what
1449
+ * finally pointed at it.
1450
+ *
1451
+ * `null` is deliberately kept: it is a value a caller chose, not an absence.
1154
1452
  */
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;
1453
+ declare function definedFields<T extends object, K extends keyof T>(data: T, keys: readonly K[]): Partial<Pick<T, K>>;
1454
+
1455
+ /** Backend capability key, convention: domain.action[.action]. */
1456
+ type CapabilityKey = string;
1457
+ /** Frontend action-registry key. Same convention as CapabilityKey. */
1458
+ type ActionKey = CapabilityKey;
1459
+ /** Lookup key in the static extensions: map of the UI build. */
1460
+ type ComponentKey = string;
1461
+ interface AdminManifest {
1462
+ schemaVersion: 1;
1463
+ project: {
1464
+ key: string;
1465
+ displayName: string;
1466
+ /** Tag/subtitle (e.g. "SuperAdmin"). From `saas.yaml#app.label`. */
1467
+ label?: string;
1468
+ /** Short abbreviation for the logo badge (e.g. "ma", "da"). From `saas.yaml#app.icon`. */
1469
+ icon?: string;
1470
+ logoUrl?: string;
1471
+ environment?: 'production' | 'staging' | 'development';
1472
+ /**
1473
+ * Allowed locale pool from the app config (`saas.yaml`
1474
+ * `marketing.availableLocales`). First = default..
1475
+ */
1476
+ availableLocales?: string[];
1477
+ /** Default locale; equals `availableLocales[0]`. */
1478
+ defaultLocale?: string;
1479
+ };
1480
+ build: {
1481
+ platformPackageVersion: string;
1482
+ appVersion: string;
1483
+ manifestHash: string;
1484
+ };
1485
+ planCatalogSnapshot: {
1486
+ source: string;
1487
+ hash: string;
1488
+ currency: string;
1489
+ vatRate: number;
1490
+ features?: FeatureDef[];
1491
+ plans: PlanDef[];
1492
+ };
1493
+ /** Map CapabilityKey → boolean. Manifest is never a security source. */
1494
+ capabilities: Record<CapabilityKey, boolean>;
1495
+ navigation: {
1496
+ standardPages: Partial<Record<StandardPageKey, StandardPageDef>>;
1497
+ projectPages?: ProjectPageDef[];
1498
+ };
1499
+ dashboard?: {
1500
+ kpiCards?: KpiCardDef[];
1501
+ };
1502
+ tenants?: {
1503
+ columns?: TenantColumnDef[];
1504
+ actions?: TenantActionDef[];
1505
+ };
1506
+ audit?: {
1507
+ actions?: AuditActionDef[];
1508
+ };
1163
1509
  }
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[];
1510
+ type StandardPageKey = 'dashboard' | 'tenants' | 'subscriptions' | 'promoCodes' | 'plans' | 'audit' | 'users' | 'pilots' | 'discovery' | 'bundles' | 'marketingCatalog' | 'platformEmail' | 'platformEmailHistory' | 'settings';
1511
+ interface StandardPageDef {
1512
+ enabled: boolean;
1513
+ requiredCapability?: CapabilityKey;
1514
+ }
1515
+ interface ProjectPageDef {
1516
+ /** `<app>.<area>`, e.g. `demoapp.datev`. */
1517
+ id: string;
1518
+ label: string;
1519
+ icon?: string;
1520
+ /** Frontend route, e.g. `/admin/datev`. */
1521
+ route: string;
1522
+ navSection?: string;
1523
+ /** Lookup in the static extensions: map of the shell build. */
1524
+ componentKey: ComponentKey;
1525
+ requiredCapability?: CapabilityKey;
1526
+ prefetchOnIdle?: boolean;
1527
+ }
1528
+ interface KpiCardDef {
1529
+ id: string;
1530
+ label: string;
1531
+ /** Required path: /api/v1/admin/(extras|dashboard)/... */
1532
+ endpoint: string;
1533
+ displayHint: KpiDisplayHint;
1534
+ /** 0–100; UI sorts descending. */
1535
+ slotPriority?: number;
1536
+ requiredCapability?: CapabilityKey;
1537
+ }
1538
+ interface KpiDisplayHint {
1539
+ type: 'value' | 'value+timestamp' | 'value+spark8w' | 'value+delta';
1540
+ icon?: string;
1541
+ }
1542
+ interface TenantColumnDef {
1543
+ key: string;
1544
+ label: string;
1545
+ /** Required path: /api/v1/admin/extras/...; MUST be batch-capable, no {slug}/{tenantId}. */
1546
+ endpoint: string;
1547
+ requiredCapability?: CapabilityKey;
1548
+ }
1549
+ interface TenantActionDef {
1550
+ /** `<app>.<area>.<verb>`, e.g. `demoapp.datev.runExport`. */
1551
+ id: string;
1552
+ label: string;
1553
+ /** Lookup in the static actions: map of the shell build. */
1554
+ actionKey: ActionKey;
1555
+ requiredCapability?: CapabilityKey;
1556
+ requiresMfa?: boolean;
1557
+ confirmType?: 'none' | 'simple' | 'typed-slug' | 'typed-production' | 'date';
1558
+ }
1559
+ interface AuditActionDef {
1560
+ /** SCREAMING_SNAKE_CASE; matched to the AuditLog.action column. */
1561
+ key: string;
1562
+ label: string;
1563
+ severity?: 'info' | 'low' | 'medium' | 'high';
1564
+ }
1565
+ interface ManifestContribution {
1566
+ capabilities?: Record<CapabilityKey, boolean>;
1567
+ navigation?: {
1568
+ standardPages?: Partial<Record<StandardPageKey, StandardPageDef>>;
1569
+ projectPages?: ProjectPageDef[];
1570
+ };
1571
+ dashboard?: {
1572
+ kpiCards?: KpiCardDef[];
1573
+ };
1574
+ tenants?: {
1575
+ columns?: TenantColumnDef[];
1576
+ actions?: TenantActionDef[];
1577
+ };
1578
+ audit?: {
1579
+ actions?: AuditActionDef[];
1580
+ };
1581
+ }
1582
+ interface PublicBootResponse {
1583
+ project: {
1584
+ key: string;
1585
+ displayName: string;
1586
+ /** Tag/subtitle (e.g. "SuperAdmin"). From `saas.yaml#app.label`. */
1587
+ label?: string;
1588
+ /** Short abbreviation for the logo badge (e.g. "ma", "da"). From `saas.yaml#app.icon`. */
1589
+ icon?: string;
1590
+ logoUrl?: string;
1591
+ environment?: 'production' | 'staging' | 'development';
1592
+ };
1593
+ }
1594
+
1595
+ /** Format: 'web:<email>:<sessionId>' or 'cli:<email>:<host>'. */
1596
+ type ActorTag = string;
1597
+ interface AuditEntry {
1598
+ id: string;
1599
+ /** null = platform action without tenant context (SUPER_ADMIN). */
1600
+ tenantId: string | null;
1601
+ /** null = system / cron-triggered. */
1602
+ userId: string | null;
1603
+ /** Convenience field; backend resolves it from userId. */
1604
+ userEmail: string | null;
1605
+ /** e.g. 'Tenant', 'PromoCode', 'Subscription', 'PlanVersion', 'User'. */
1606
+ entity: string;
1607
+ entityId: string;
1608
+ /** SCREAMING_SNAKE_CASE; past-tense oriented. */
1609
+ action: string;
1610
+ /** Freely structured. Convention: { field: { old, new } } or { reason, ... }. */
1611
+ changes: Record<string, unknown> | null;
1612
+ actorTag: ActorTag | null;
1613
+ ipAddress: string | null;
1614
+ userAgent: string | null;
1615
+ createdAt: string;
1616
+ }
1617
+ interface AuditQuery {
1618
+ tenantId?: string;
1619
+ userId?: string;
1620
+ entity?: string;
1621
+ entityId?: string;
1622
+ action?: string;
1623
+ /** Wildcard-capable, e.g. 'cli:*'. */
1624
+ actorTag?: string;
1625
+ from?: string;
1626
+ to?: string;
1627
+ page?: number;
1628
+ pageSize?: number;
1174
1629
  }
1175
1630
 
1176
1631
  /** Promotion type. */
@@ -1201,7 +1656,6 @@ type PromotionI18n = Record<string, PromotionI18nFields>;
1201
1656
  /** Wire format of a `promotions` row. */
1202
1657
  interface PromotionRow {
1203
1658
  id: string;
1204
- projectKey: string;
1205
1659
  /** Internal label (not public). */
1206
1660
  internalLabel: string;
1207
1661
  type: PromotionType;
@@ -1227,11 +1681,7 @@ interface PromotionRow {
1227
1681
  createdAt: string;
1228
1682
  updatedAt: string;
1229
1683
  }
1230
- interface PromotionFilter {
1231
- projectKey: string;
1232
- }
1233
1684
  interface CreatePromotionData {
1234
- projectKey: string;
1235
1685
  internalLabel: string;
1236
1686
  type: PromotionType;
1237
1687
  value: PromotionValue;
@@ -1293,8 +1743,35 @@ type PromotionResult = {
1293
1743
  original: number;
1294
1744
  months: number;
1295
1745
  };
1296
- /** Applies the promotion math to a base price. */
1746
+ /**
1747
+ * Applies the promotion math to a base price, or `null` where the promotion
1748
+ * takes nothing off it.
1749
+ *
1750
+ * The discounted price stays between 0 and the base price, whatever the
1751
+ * promotion states: a promotion lowers the price of what it is on and nothing
1752
+ * else. Creating one refuses a value no price could make sense of, but whether
1753
+ * an intro price or an amount fits depends on the price it meets — which
1754
+ * differs per plan and rhythm and moves when a new version is published — so
1755
+ * the bound is held here, where every place that resolves a promotion reads it:
1756
+ * the public catalogue, a checkout offer, the operator's preview.
1757
+ */
1297
1758
  declare function applyPromo(promo: PromotionRow | null, basePrice: number | null): PromotionResult | null;
1759
+ /** A promotion on a price, and what it makes of that price. */
1760
+ interface PromotionOnPrice {
1761
+ promotion: PromotionRow;
1762
+ result: PromotionResult;
1763
+ }
1764
+ /**
1765
+ * The promotion a price carries: the one `pickActivePromo` selects for the key,
1766
+ * language and rhythm, where `applyPromo` finds that it lowers the price at
1767
+ * all — or `null`.
1768
+ *
1769
+ * One question, asked the same way by every place that shows or charges a
1770
+ * promotion: the public catalogue, a checkout offer, and the operator's
1771
+ * preview. A promotion that lowers nothing is no promotion there, so it carries
1772
+ * no badge and takes nothing off.
1773
+ */
1774
+ declare function promotionOnPrice(promotions: PromotionRow[], targetKey: string, locale: string, cycle: 'monthly' | 'yearly', basePrice: number | null, today?: Date, targetType?: PromotionTargetType): PromotionOnPrice | null;
1298
1775
 
1299
1776
  type CheckoutOfferLineItemKind = 'plan' | 'bundle' | 'discount';
1300
1777
  /** Frozen billable line item in the offer. */
@@ -1346,6 +1823,7 @@ interface CheckoutOfferPriceBreakdown {
1346
1823
  regularNet: number;
1347
1824
  /** Net total after promo. */
1348
1825
  effectiveNet: number;
1826
+ /** VAT rate in percent, as the plan catalogue names it (19 = 19 %). */
1349
1827
  vatRate: number;
1350
1828
  /** Gross total after promo. */
1351
1829
  effectiveGross: number;
@@ -1354,7 +1832,6 @@ type CheckoutOfferStatus = 'open' | 'consumed' | 'expired';
1354
1832
  /** Wire format of a `checkout_offers` row. */
1355
1833
  interface CheckoutOfferRow {
1356
1834
  id: string;
1357
- projectKey: string;
1358
1835
  /** Plan selected on the website. */
1359
1836
  planKey: string;
1360
1837
  /** Resolved plan version, if known. */
@@ -1362,7 +1839,7 @@ interface CheckoutOfferRow {
1362
1839
  billingCycle: 'monthly' | 'yearly';
1363
1840
  /** Applied promotion (active at offer time). */
1364
1841
  promotionId: string | null;
1365
- /** Redeemed promo code, if the promotion was `requiresCoupon`. */
1842
+ /** Promo code applied to the offer, as the promo module normalised it. */
1366
1843
  promoCode: string | null;
1367
1844
  /** Added bundle keys. Legacy display; V3 uses `bundleVersionIds` + `lineItems`. */
1368
1845
  bundles: string[];
@@ -1385,12 +1862,44 @@ interface CheckoutOfferRow {
1385
1862
  updatedAt: string;
1386
1863
  }
1387
1864
  interface CheckoutOfferFilter {
1388
- projectKey: string;
1389
1865
  status?: CheckoutOfferStatus;
1390
1866
  }
1391
- /** Body of `POST /public/checkout-offer` — called from the website. */
1867
+ /**
1868
+ * What a caller chooses: the body of `POST /public/checkout-offer`, and the
1869
+ * input of `CheckoutOfferService.create`.
1870
+ *
1871
+ * No amount is part of it. The plan version, the bundle prices, the promotion
1872
+ * and the promo code discount are resolved on the server, so the offer costs
1873
+ * what the catalogue says rather than what a request says.
1874
+ */
1875
+ interface CheckoutOfferSelection {
1876
+ planKey: string;
1877
+ billingCycle: 'monthly' | 'yearly';
1878
+ /** Concrete BundleVersion IDs to book with the plan. */
1879
+ bundleVersionIds?: string[];
1880
+ /** A promo code to apply; refused when the promo module cannot accept it. */
1881
+ promoCode?: string | null;
1882
+ locale?: string;
1883
+ validUntil?: string | null;
1884
+ }
1885
+ /**
1886
+ * What a caller may change while an offer is open: the body of
1887
+ * `PATCH /public/checkout-offer/:id`. The plan is fixed; everything given here
1888
+ * is priced again.
1889
+ */
1890
+ interface CheckoutOfferSelectionUpdate {
1891
+ billingCycle?: 'monthly' | 'yearly';
1892
+ bundleVersionIds?: string[];
1893
+ /** `null` removes a code applied before. */
1894
+ promoCode?: string | null;
1895
+ locale?: string;
1896
+ validUntil?: string | null;
1897
+ }
1898
+ /**
1899
+ * A new offer as the repository stores it — the selection with the amounts
1900
+ * the server computed for it (`CheckoutOfferRepository.create`).
1901
+ */
1392
1902
  interface CreateCheckoutOfferData {
1393
- projectKey: string;
1394
1903
  planKey: string;
1395
1904
  planVersionId?: string | null;
1396
1905
  billingCycle: 'monthly' | 'yearly';
@@ -1406,11 +1915,13 @@ interface CreateCheckoutOfferData {
1406
1915
  validUntil?: string | null;
1407
1916
  }
1408
1917
  /**
1409
- * Body of `PATCH /public/checkout-offer/:id` — customization during
1410
- * onboarding. `status`/`consumedAt` are not editable — `consume()`
1411
- * sets them server-side.
1918
+ * A change to an open offer as the repository stores it, with the amounts
1919
+ * priced again (`CheckoutOfferRepository.update`). `status`/`consumedAt` are
1920
+ * not editable — `consume()` sets them server-side.
1412
1921
  */
1413
1922
  interface UpdateCheckoutOfferData {
1923
+ /** The plan version active when the change was priced. */
1924
+ planVersionId?: string | null;
1414
1925
  billingCycle?: 'monthly' | 'yearly';
1415
1926
  promotionId?: string | null;
1416
1927
  promoCode?: string | null;
@@ -1426,7 +1937,6 @@ interface UpdateCheckoutOfferData {
1426
1937
 
1427
1938
  /** Wire format of the `marketing_settings` row. */
1428
1939
  interface MarketingSettingsRow {
1429
- projectKey: string;
1430
1940
  /** Runtime-activated subset of the `availableLocales` pool. */
1431
1941
  activeLocales: string[];
1432
1942
  updatedAt: string;
@@ -1462,6 +1972,10 @@ interface PublicMarketingPlan {
1462
1972
  badge: string;
1463
1973
  /** Teaser / description text. */
1464
1974
  description: string;
1975
+ /**
1976
+ * The recommended plan, and at most one card in a catalogue carries it —
1977
+ * see `keepOneRecommended`, which decides it per language served.
1978
+ */
1465
1979
  highlight: boolean;
1466
1980
  /**
1467
1981
  * Formatted pricing tag from the MarketingProjection (#47, e.g.
@@ -1542,13 +2056,32 @@ interface PublicComparisonRow {
1542
2056
  /** Quotas only: display unit. */
1543
2057
  unit?: string;
1544
2058
  }
2059
+ /**
2060
+ * What this installation takes a new payment method with, so a page offering the
2061
+ * plans can say it before anybody reaches the form.
2062
+ *
2063
+ * It is the account `config/saas.yaml#payments.newPaymentMethods` names, with the
2064
+ * gateway bound for it — the same account a sign-up's payment step and a tenant's
2065
+ * own change both go to. Whether an installation runs sign-ups at all is not part
2066
+ * of it: that is the application's own wiring, and the application knows it.
2067
+ *
2068
+ * Which account it is and who keeps the payment method stay inside: a prospect is
2069
+ * told that a card or a direct debit will be asked for, not where it is kept.
2070
+ */
2071
+ interface PublicNewPaymentMethods {
2072
+ /** Whether a new payment method is taken here at all. */
2073
+ taken: boolean;
2074
+ /** The methods the form offers, in the order the installation names them; empty where none is taken. */
2075
+ methods: PaymentMethodType[];
2076
+ }
1545
2077
  /** Response of `GET /public/marketing-catalog`. */
1546
2078
  interface PublicMarketingCatalogResponse {
1547
- projectKey: string;
1548
2079
  locale: string;
1549
2080
  currency: string;
1550
2081
  /** VAT rate in percent — for the CheckoutOffer price breakdown. */
1551
2082
  vatRate: number;
2083
+ /** What a new payment method is taken with here, if one is taken. */
2084
+ newPaymentMethods: PublicNewPaymentMethods;
1552
2085
  /** Visible, marketed plans — sorted by `priority` DESC. */
1553
2086
  plans: PublicMarketingPlan[];
1554
2087
  /**
@@ -1654,7 +2187,7 @@ interface DiscoverySnapshot {
1654
2187
  /** ISO timestamp of the boot-time scan. */
1655
2188
  scannedAt: string;
1656
2189
  app: {
1657
- /** projectKey, same concept as in the catalog tables. */
2190
+ /** The application's name, from `saas.yaml#app.name`. */
1658
2191
  key: string;
1659
2192
  /** Backend version, e.g. from package.json. */
1660
2193
  version: string;
@@ -1690,6 +2223,18 @@ interface FeatureUiMeta {
1690
2223
  /** Map FeatureKey → UI metadata. Consumer apps supply a complete table. */
1691
2224
  type FeatureUiRegistry = Record<string, FeatureUiMeta>;
1692
2225
 
2226
+ /** A quota as a finite number, or `null` where the value cannot be read as one. */
2227
+ declare function readQuotaValue(value: unknown): number | null;
2228
+ /**
2229
+ * Every quota in a JSON column, for a caller that computes with them.
2230
+ *
2231
+ * A key that is there stays there. Dropping an unreadable one made it *absent*,
2232
+ * and absent means undeclared: `enforceLimit` answers an undeclared dimension
2233
+ * with a 500, so every operation on that quota was refused — a fail-closed
2234
+ * answer to somebody else's corrupt row.
2235
+ */
2236
+ declare function readQuotaRecord(value: unknown): Record<string, number>;
2237
+
1693
2238
  /** Alias for historical compatibility — equivalent to VersionChangeDirection. */
1694
2239
  type ChangeDirection = VersionChangeDirection;
1695
2240
  interface DiffResult {
@@ -1707,9 +2252,16 @@ type DecimalLike = number | string | {
1707
2252
  };
1708
2253
  interface PlanVersionFields {
1709
2254
  features: FeatureKey[];
1710
- maxUsers: number;
1711
- maxVehicles: number;
1712
- maxStorageGb: number;
2255
+ /**
2256
+ * Quotas of the version. -1 = unlimited; missing key = 0.
2257
+ *
2258
+ * Every key either side carries is compared. Which keys exist is the
2259
+ * installation's decision — they come from `@DefinesQuota` — so a fixed
2260
+ * set here would have compared the three the platform happened to know by
2261
+ * name and let every other one be lowered without the confirmation
2262
+ * publishing a regression asks for.
2263
+ */
2264
+ quotas: Record<QuotaKey, number>;
1713
2265
  monthlyNet: DecimalLike;
1714
2266
  yearlyNet: DecimalLike;
1715
2267
  }
@@ -1725,8 +2277,7 @@ declare function classifyPlanDiff(oldV: PlanVersionFields, newV: PlanVersionFiel
1725
2277
  /**
1726
2278
  * Classification of a BundleVersion diff for contract protection.
1727
2279
  *
1728
- * Quota comparison: `-1` (unlimited) is always better than any positive
1729
- * number. Otherwise higher = better. Missing keys are treated as 0.
2280
+ * Quotas are compared exactly as they are for a plan.
1730
2281
  *
1731
2282
  * Pricing can be `null` (the bundle only has override pricing); a switch
1732
2283
  * from value ↔ null is classified as REGRESSION (value dropped) or IMPROVEMENT
@@ -1769,6 +2320,11 @@ interface PromoPreviewValidResponse {
1769
2320
  /** Decimal-as-string, e.g. "199.00". */
1770
2321
  originalGross: string;
1771
2322
  discountGross: string;
2323
+ /**
2324
+ * `discountGross` in net, converted at the installation's VAT rate —
2325
+ * the figure a page showing net prices takes off the plan price.
2326
+ */
2327
+ discountNet: string;
1772
2328
  discountedGross: string;
1773
2329
  includedVat: string;
1774
2330
  nextRegularAmountGross: string;
@@ -1824,9 +2380,94 @@ interface OnboardingPromoRedemption {
1824
2380
  endsAt: string | null;
1825
2381
  }
1826
2382
 
2383
+ /**
2384
+ * The settings subtree of a plan catalogue: every top-level block that is
2385
+ * configuration rather than the catalogue itself. JSON-shaped, because it is
2386
+ * stored as JSON and compared as JSON.
2387
+ */
2388
+ type AppliedSettingsValues = Record<string, unknown>;
2389
+ /** The one row per installation: what is applied, since when, and from where. */
2390
+ interface AppliedSettingsRecord {
2391
+ /**
2392
+ * `sha256-<hex>` over the canonical JSON of `settings`. Two boots with the
2393
+ * same resolved values produce the same fingerprint however the file was
2394
+ * formatted, and a plan added to the catalogue does not move it.
2395
+ */
2396
+ fingerprint: string;
2397
+ settings: AppliedSettingsValues;
2398
+ /**
2399
+ * Where the values came from: the absolute path of the file the platform
2400
+ * read, or a phrase saying they were handed to it in code.
2401
+ */
2402
+ source: string;
2403
+ /** The moment these values became the running configuration. */
2404
+ appliedAt: Date;
2405
+ }
2406
+ /** What a boot noticed had changed since the previous record. */
2407
+ interface SettingsChangeRecord {
2408
+ id: string;
2409
+ /** The boot that noticed the difference and applied the new values. */
2410
+ noticedAt: Date;
2411
+ source: string;
2412
+ previous: AppliedSettingsValues;
2413
+ current: AppliedSettingsValues;
2414
+ /** Set once an operator has seen it; null while it is still owed a look. */
2415
+ acknowledgedAt: Date | null;
2416
+ /** Who acknowledged it — an actor tag, as the audit log writes it. */
2417
+ acknowledgedBy: string | null;
2418
+ }
2419
+ type NewSettingsChange = Pick<SettingsChangeRecord, 'noticedAt' | 'source' | 'previous' | 'current'>;
2420
+ /** One leaf that differs between two settings subtrees. */
2421
+ interface SettingsDifference {
2422
+ /** Dotted path, as the loader names a field: `tenantBilling.cancellationNoticeDays.monthly`. */
2423
+ path: string;
2424
+ /** `undefined` where the leaf did not exist on that side. */
2425
+ before: unknown;
2426
+ after: unknown;
2427
+ }
2428
+
2429
+ /**
2430
+ * The top-level blocks of `config/saas.yaml` that are the catalogue rather than
2431
+ * the configuration, and the format marker.
2432
+ *
2433
+ * An exclusion list rather than a list of settings, on purpose: a block the
2434
+ * schema gains tomorrow is a setting until somebody says otherwise, so it is
2435
+ * fingerprinted by default. The failure mode of the other list — a new setting
2436
+ * silently left out of the fingerprint, so a change to it is never noticed — is
2437
+ * the one this record exists to prevent. `schemaVersion` is excluded because a
2438
+ * format change is a migration of the file, not a decision an operator took.
2439
+ *
2440
+ * `tests/settings-subtree.test.js` holds this list to the schema in both
2441
+ * directions: every name here is a property the schema declares, and every
2442
+ * property the schema declares lands on one side.
2443
+ */
2444
+ declare const CATALOGUE_KEYS: ReadonlySet<keyof PlanCatalog>;
2445
+ /**
2446
+ * The settings of a catalogue, typed, for handing them on as a whole.
2447
+ *
2448
+ * The same selection as `settingsSubtreeOf`, which is why it is that function:
2449
+ * a caller that listed the blocks it passes on would drop the next one the
2450
+ * schema gains, and nothing would say so.
2451
+ */
2452
+ declare function planCatalogSettingsOf(catalog: PlanCatalog): PlanCatalogSettings;
2453
+ /** Everything in the catalogue that is configuration, as it was resolved. */
2454
+ declare function settingsSubtreeOf(catalog: PlanCatalog | PlanCatalogSettings): AppliedSettingsValues;
2455
+ /**
2456
+ * `JSON.stringify` with object keys in sorted order at every depth, so that two
2457
+ * documents saying the same thing in a different order serialise identically.
2458
+ * Array order is kept: a list is what its author wrote, in the order they wrote
2459
+ * it.
2460
+ */
2461
+ declare function canonicalJson(value: unknown): string;
2462
+ /**
2463
+ * Every leaf that differs between `before` and `after`, in the order the paths
2464
+ * sort. A list counts as one leaf: `asTarget: [] → [ENTERPRISE]` is one thing
2465
+ * that changed, not a change per element.
2466
+ */
2467
+ declare function diffSettings(before: AppliedSettingsValues, after: AppliedSettingsValues): SettingsDifference[];
2468
+
1827
2469
  interface PlanRow {
1828
2470
  id: string;
1829
- projectKey: string;
1830
2471
  planKey: string;
1831
2472
  label: string;
1832
2473
  description: string | null;
@@ -1843,7 +2484,6 @@ interface PlanRow {
1843
2484
  * creation (follows in M6 Pack 2).
1844
2485
  */
1845
2486
  interface CreatePlanData {
1846
- projectKey: string;
1847
2487
  planKey: string;
1848
2488
  label: string;
1849
2489
  description?: string | null;
@@ -1851,9 +2491,9 @@ interface CreatePlanData {
1851
2491
  sortOrder?: number;
1852
2492
  }
1853
2493
  /**
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.
2494
+ * Fields that may be changed on the plan stem. `planKey` is deliberately not
2495
+ * here — stem identity is immutable; whoever wants to change it creates a new
2496
+ * plan and retires the old one.
1857
2497
  */
1858
2498
  interface UpdatePlanData {
1859
2499
  label?: string;
@@ -1917,7 +2557,6 @@ interface UpsertResult {
1917
2557
  skipReason?: string;
1918
2558
  }
1919
2559
  interface UpsertPlanInput {
1920
- projectKey: string;
1921
2560
  planKey: string;
1922
2561
  label: string;
1923
2562
  description?: string | null;
@@ -1937,7 +2576,6 @@ interface UpsertPlanVersionInput {
1937
2576
  changeNote: string;
1938
2577
  }
1939
2578
  interface UpsertFeatureCatalogEntryInput {
1940
- projectKey: string;
1941
2579
  featureKey: FeatureKey;
1942
2580
  label?: string;
1943
2581
  icon?: string;
@@ -1983,7 +2621,7 @@ interface PlanCatalogReadSnapshot {
1983
2621
  * implement it against their Prisma tables.
1984
2622
  */
1985
2623
  interface PlanCatalogReadSink {
1986
- loadSnapshot(projectKey: string): Promise<PlanCatalogReadSnapshot>;
2624
+ loadSnapshot(): Promise<PlanCatalogReadSnapshot>;
1987
2625
  }
1988
2626
 
1989
2627
  /**
@@ -2220,6 +2858,26 @@ interface PasswordHasher {
2220
2858
  hash(plain: string): Promise<string>;
2221
2859
  verify(hash: string, plain: string): Promise<boolean>;
2222
2860
  }
2861
+ /**
2862
+ * Sends a plain-text mail to an operator.
2863
+ *
2864
+ * The platform composes the text; the adapter delivers it — over whatever the
2865
+ * installation already sends mail with. Deliberately narrow: no templates, no
2866
+ * locale, no HTML. The one thing the platform mails today is a diagnostic for
2867
+ * the operator who runs the installation, and a diagnostic is English and
2868
+ * plain, like the boot log it mirrors. Tenant-facing mail — a verification
2869
+ * code, a resume link — goes through the registration module's own delivery
2870
+ * ports, which carry the locale and the person's name because that mail is
2871
+ * for a customer.
2872
+ */
2873
+ interface EmailPort {
2874
+ /** Delivers one plain-text mail to one address; rejects when it cannot. */
2875
+ send(message: {
2876
+ to: string;
2877
+ subject: string;
2878
+ text: string;
2879
+ }): Promise<void>;
2880
+ }
2223
2881
  /** Adapter for MFA secret persistence. */
2224
2882
  interface MfaPort {
2225
2883
  /** Returns the stored TOTP secret or null. */
@@ -2261,6 +2919,11 @@ interface PromoCodeRecord {
2261
2919
  validUntil: Date | null;
2262
2920
  maxRedemptions: number | null;
2263
2921
  redemptionsCount: number;
2922
+ /**
2923
+ * Slots held for checkouts that started and have not concluded
2924
+ * (`PromoCodeHoldRepository`). An adapter without holds reports 0.
2925
+ */
2926
+ heldCount: number;
2264
2927
  appliesToPlans: string[];
2265
2928
  appliesToBilling: BillingCycle | null;
2266
2929
  firstTimeCustomersOnly: boolean;
@@ -2350,7 +3013,7 @@ interface PromoCodeRedemptionListItem extends PromoCodeRedemptionRecord {
2350
3013
  /**
2351
3014
  * Adapter for PromoCode persistence. Atomic slot reservation lives in the
2352
3015
  * adapter because it is DB-specific (Postgres `UPDATE ... WHERE ... AND
2353
- * (maxRedemptions IS NULL OR redemptionsCount < maxRedemptions)`).
3016
+ * (maxRedemptions IS NULL OR redemptionsCount + heldCount < maxRedemptions)`).
2354
3017
  */
2355
3018
  interface PromoCodeRepository {
2356
3019
  findById(id: string): Promise<PromoCodeRecord | null>;
@@ -2361,12 +3024,17 @@ interface PromoCodeRepository {
2361
3024
  softDelete(id: string): Promise<void>;
2362
3025
  /**
2363
3026
  * Atomic slot reservation: increments `redemptionsCount` and checks
2364
- * `status === 'ACTIVE' && (maxRedemptions IS NULL || redemptionsCount < maxRedemptions)`.
3027
+ * `status === 'ACTIVE' && (maxRedemptions IS NULL || redemptionsCount + heldCount < maxRedemptions)`.
2365
3028
  * Returns true if the slot was reserved, false if EXHAUSTED
2366
- * or the status is not ACTIVE.
3029
+ * or the status is not ACTIVE. A slot held for a checkout is not free, so
3030
+ * an adapter that keeps holds counts `heldCount` here.
2367
3031
  */
2368
3032
  claimSlot(id: string, tx?: TransactionContext): Promise<boolean>;
2369
- /** Sets the status to `EXHAUSTED` when `redemptionsCount >= maxRedemptions`. */
3033
+ /**
3034
+ * Sets the status to `EXHAUSTED` when `redemptionsCount >= maxRedemptions`.
3035
+ * Holds do not count: a code full only because of held slots stays ACTIVE,
3036
+ * and gets its slots back when the holds end.
3037
+ */
2370
3038
  markExhaustedIfFull(id: string, tx?: TransactionContext): Promise<void>;
2371
3039
  /** Decrements `redemptionsCount` by 1 (min 0); EXHAUSTED → ACTIVE. */
2372
3040
  releaseSlot(id: string, tx?: TransactionContext): Promise<void>;
@@ -2376,6 +3044,98 @@ interface PromoCodeRepository {
2376
3044
  */
2377
3045
  expireDueCodes(now: Date): Promise<number>;
2378
3046
  }
3047
+ /**
3048
+ * A slot of a code kept for a checkout offer, from the start of its checkout
3049
+ * until the checkout concludes, the offer's code changes, or `expiresAt`
3050
+ * passes, whichever comes first. For a sign-up, `expiresAt` is the last moment
3051
+ * a confirmation of its payment form can arrive
3052
+ * (`PaymentMethodSetupSession.confirmableUntil`), not the checkout's lifetime.
3053
+ */
3054
+ interface PromoCodeHoldRecord {
3055
+ id: string;
3056
+ promoCodeId: string;
3057
+ checkoutOfferId: string;
3058
+ expiresAt: Date;
3059
+ createdAt: Date;
3060
+ }
3061
+ /** What `PromoCodeHoldRepository.take` did. */
3062
+ type PromoCodeHoldTaken = {
3063
+ outcome: 'taken';
3064
+ hold: PromoCodeHoldRecord;
3065
+ }
3066
+ /** The code is not ACTIVE, is deleted, or has no free slot. */
3067
+ | {
3068
+ outcome: 'no-slot';
3069
+ }
3070
+ /** The offer holds a slot already, perhaps taken by a concurrent call. */
3071
+ | {
3072
+ outcome: 'offer-holds-one';
3073
+ };
3074
+ /**
3075
+ * Adapter for the slots a code keeps for checkouts. Every method that ends a
3076
+ * hold deletes its row and gives its slot back in one statement, so a hold is
3077
+ * counted in `PromoCodeRecord.heldCount` exactly as long as its row exists.
3078
+ *
3079
+ * Optional in the promo module: an adapter that has no holds leaves
3080
+ * `heldCount` at 0, and taking a hold then refuses to start rather than
3081
+ * quietly holding nothing.
3082
+ */
3083
+ interface PromoCodeHoldRepository {
3084
+ /** The offer's hold, live or past its expiry and not yet given back. */
3085
+ findByCheckoutOffer(checkoutOfferId: string, tx?: TransactionContext): Promise<PromoCodeHoldRecord | null>;
3086
+ /**
3087
+ * Takes a slot of an ACTIVE, undeleted code for the offer, atomically with
3088
+ * `claimSlot`'s rule: `maxRedemptions IS NULL OR redemptionsCount +
3089
+ * heldCount < maxRedemptions`. An offer holds one slot at most. Runs on a
3090
+ * transaction of its own: a checkout starts outside any other, and the
3091
+ * slot is committed before the gateway's form opens.
3092
+ */
3093
+ take(hold: {
3094
+ promoCodeId: string;
3095
+ checkoutOfferId: string;
3096
+ expiresAt: Date;
3097
+ }): Promise<PromoCodeHoldTaken>;
3098
+ /**
3099
+ * Moves the expiry of the offer's hold on that code to `expiresAt`, and
3100
+ * never earlier than it stands: the later of the two is written in the one
3101
+ * statement, so a start of the same checkout that asks for less cannot
3102
+ * shorten the slot a form opened by another start relies on, however the
3103
+ * two interleave. False when the offer holds no slot of it any more — it
3104
+ * ended in the meantime.
3105
+ */
3106
+ extend(checkoutOfferId: string, promoCodeId: string, expiresAt: Date): Promise<boolean>;
3107
+ /** Ends the offer's hold and gives its slot back. False when it had none. */
3108
+ release(checkoutOfferId: string, tx?: TransactionContext): Promise<boolean>;
3109
+ /**
3110
+ * Ends the offer's hold and gives its slot back only while it still expires
3111
+ * at `expiresAt` — the hold as the caller wrote it. A hold another start
3112
+ * moved since stays, and so does its slot, in the same statement. False when
3113
+ * nothing was given back.
3114
+ */
3115
+ releaseIfUnmoved(checkoutOfferId: string, expiresAt: Date): Promise<boolean>;
3116
+ /**
3117
+ * Marks the offer's hold, if it is live at `now`, as the slot of the
3118
+ * redemption that runs on `tx`. The mark never outlives the transaction: the
3119
+ * redemption turns the hold into its slot (`convertHandedOver`), or the
3120
+ * caller releases it before committing. False when the offer has no live
3121
+ * hold.
3122
+ */
3123
+ handOver(checkoutOfferId: string, now: Date, tx: TransactionContext): Promise<boolean>;
3124
+ /**
3125
+ * Turns the hold of that code handed over on `tx` into a redemption's slot:
3126
+ * the row goes, `heldCount` drops by one and `redemptionsCount` rises by one,
3127
+ * whatever the code's status. False when nothing was handed over on `tx`.
3128
+ */
3129
+ convertHandedOver(promoCodeId: string, tx: TransactionContext): Promise<boolean>;
3130
+ /**
3131
+ * Ends every hold past its expiry at `now` — of one code when `promoCodeId`
3132
+ * is given — and gives the slots back. A hold handed over on `tx` is left
3133
+ * to the conclusion running there; one handed over on another running
3134
+ * transaction is waited for, and is gone once that transaction ends.
3135
+ * Returns how many ended.
3136
+ */
3137
+ expireDue(now: Date, promoCodeId?: string, tx?: TransactionContext): Promise<number>;
3138
+ }
2379
3139
  /** Adapter for PromoCodeRedemption persistence. */
2380
3140
  interface PromoCodeRedemptionRepository {
2381
3141
  findBySubscription(subscriptionId: string, tx?: TransactionContext): Promise<PromoCodeRedemptionRecord | null>;
@@ -2427,6 +3187,26 @@ interface PromoRevenueDeductionAggregator {
2427
3187
 
2428
3188
  type ContractLineItemKind = 'plan' | 'bundle' | 'discount';
2429
3189
  type SubscriptionContractStatus = 'active' | 'scheduled' | 'terminated' | 'superseded';
3190
+ /**
3191
+ * The statuses a contract is looked up under when asking "what is this tenant
3192
+ * on right now" — `scheduled` included, because a contract that starts today
3193
+ * and has not been switched to `active` yet is still the one in force at its
3194
+ * own `effectiveFrom`.
3195
+ *
3196
+ * One list rather than one per adapter: the two adapters have to answer
3197
+ * `findActiveByTenantId` the same way, and a status added here must not reach
3198
+ * only whichever of them somebody remembered.
3199
+ */
3200
+ /**
3201
+ * How many bundle versions one price lookup may name.
3202
+ *
3203
+ * One number rather than two: the server validates against it and the client
3204
+ * batches to stay inside it, and a client that learned the cap by receiving a
3205
+ * 400 would fail silently — the lookup answers with an empty map, and every
3206
+ * card falls back to a catalogue price the tenant may not be charged.
3207
+ */
3208
+ declare const BUNDLE_PRICE_LOOKUP_LIMIT = 200;
3209
+ declare const ACTIVE_SUBSCRIPTION_CONTRACT_STATUSES: readonly SubscriptionContractStatus[];
2430
3210
  interface ContractLineItemRecord {
2431
3211
  id: string;
2432
3212
  contractId: string;
@@ -2437,9 +3217,36 @@ interface ContractLineItemRecord {
2437
3217
  descriptionSnapshot: string | null;
2438
3218
  quantity: number;
2439
3219
  unit: string | null;
3220
+ /**
3221
+ * What the line costs over one of its billing periods, `quantity` already
3222
+ * in it — not a unit price. A contract's totals take its lines as they
3223
+ * stand, each counted as often as it falls due in one period of the
3224
+ * contract, and are refused where they do not add up.
3225
+ */
2440
3226
  priceNet: number;
3227
+ /** `priceNet` with the line's share of the tax its rhythm owes. */
2441
3228
  priceGross: number;
2442
3229
  billingCycle: 'monthly' | 'yearly';
3230
+ /**
3231
+ * ISO 4217, as the line was booked in.
3232
+ *
3233
+ * An installation sells in one currency at a time, so this is never a
3234
+ * choice the line makes — it is what keeps the line meaning what it meant
3235
+ * after the configured currency is migrated to another one.
3236
+ */
3237
+ currency: string;
3238
+ /**
3239
+ * The tax rate in percent that was applied, recorded rather than left in
3240
+ * the ratio between net and gross. That ratio is not the rate: it cannot be
3241
+ * reproduced for a rounded gross, cannot express an exempt or reverse-charge
3242
+ * line, and does not survive a rate change.
3243
+ */
3244
+ taxRate: number;
3245
+ /**
3246
+ * The tax contained in the line — exactly `priceGross - priceNet`, so the
3247
+ * line cannot disagree with itself. Rounded once, when the line is written.
3248
+ */
3249
+ taxAmount: number;
2443
3250
  minimumTermUntil: Date | null;
2444
3251
  featuresSnapshot: string[];
2445
3252
  quotaEffectsSnapshot: Record<string, number>;
@@ -2452,13 +3259,47 @@ interface SubscriptionContractPriceSnapshot {
2452
3259
  subtotalNet: number;
2453
3260
  discountNet: number;
2454
3261
  totalNet: number;
3262
+ /**
3263
+ * The tax rate this contract's total was computed at, as a percentage:
3264
+ * 19 means 19 %, as every tax rate in SaaSiCat is.
3265
+ */
2455
3266
  vatRate: number;
2456
3267
  totalGross: number;
2457
3268
  }
2458
- interface SubscriptionContractRecord {
3269
+ /**
3270
+ * The subscriber as a contract copied it on the day it was concluded.
3271
+ *
3272
+ * The invoice email is not part of it: it says how the party is reached, not
3273
+ * who the party is, and a contract is kept for years after an address like that
3274
+ * stopped mattering.
3275
+ */
3276
+ interface ContractSubscriberParty extends LegalIdentity, PartyAddress {
3277
+ customerNumber: string;
3278
+ }
3279
+ /** The issuer as `config/saas.yaml` named it on the day a contract was concluded. */
3280
+ interface ContractIssuerParty extends LegalIdentity, PartyAddress {
3281
+ }
3282
+ /** Who a contract is between, copied when it is concluded. */
3283
+ interface SubscriptionContractParties {
3284
+ subscriberId: string;
3285
+ subscriber: ContractSubscriberParty;
3286
+ /** `null` where `config/saas.yaml` named no issuer that day. */
3287
+ issuer: ContractIssuerParty | null;
3288
+ }
3289
+ interface SubscriptionContractRecord extends SubscriptionContractParties {
2459
3290
  id: string;
2460
- projectKey: string;
3291
+ /**
3292
+ * The tenant the contract was concluded for, kept as a trace. The contract
3293
+ * belongs to its subscriber and outlives the tenant.
3294
+ */
2461
3295
  tenantId: string;
3296
+ /**
3297
+ * The parties were copied by the migration that attached contracts
3298
+ * concluded before subscribers existed, not on the day the contract was
3299
+ * concluded. Either party may have changed in between, so such a copy is
3300
+ * never presented as what was agreed.
3301
+ */
3302
+ partiesMigrated: boolean;
2462
3303
  status: SubscriptionContractStatus;
2463
3304
  effectiveFrom: Date;
2464
3305
  effectiveUntil: Date | null;
@@ -2475,8 +3316,12 @@ interface SubscriptionContractRecord {
2475
3316
  updatedAt: Date;
2476
3317
  }
2477
3318
  type NewContractLineItemData = Omit<ContractLineItemRecord, 'id' | 'contractId' | 'createdAt'>;
3319
+ /**
3320
+ * A contract as a caller asks for it. The parties are not among it: the
3321
+ * platform copies them from the tenant's subscriber and the configuration when
3322
+ * the contract is written, so no caller can name a party of its own.
3323
+ */
2478
3324
  interface CreateSubscriptionContractData {
2479
- projectKey: string;
2480
3325
  tenantId: string;
2481
3326
  status?: SubscriptionContractStatus;
2482
3327
  effectiveFrom: Date;
@@ -2491,16 +3336,53 @@ interface CreateSubscriptionContractData {
2491
3336
  termsSnapshot?: Record<string, unknown> | null;
2492
3337
  lineItems: NewContractLineItemData[];
2493
3338
  }
3339
+ /** What a repository writes: the contract as asked for, with the parties the platform copied. */
3340
+ interface NewSubscriptionContractData extends CreateSubscriptionContractData {
3341
+ parties: SubscriptionContractParties;
3342
+ }
2494
3343
  interface TerminateSubscriptionContractData {
2495
3344
  effectiveUntil: Date;
2496
- status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'>;
3345
+ /**
3346
+ * The terminal status, or `null` to end the contract by date alone.
3347
+ *
3348
+ * `findActiveByTenantId` already asks its question as a window —
3349
+ * `effectiveFrom <= asOf` and `effectiveUntil` null or after it — so a
3350
+ * contract given an end in the FUTURE is found until that moment and not
3351
+ * afterwards, with no scheduled job to flip anything.
3352
+ *
3353
+ * Writing a terminal status instead makes the contract disappear from that
3354
+ * lookup at once, which for a cancellation declared months ahead removes an
3355
+ * agreement the customer is still under. Null is how a caller says "it ends
3356
+ * then", and a status is how it says "it is over now".
3357
+ */
3358
+ status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'> | null;
2497
3359
  }
2498
3360
  interface SubscriptionContractFilter {
2499
- projectKey?: string;
2500
3361
  tenantId?: string;
2501
3362
  status?: SubscriptionContractStatus;
2502
3363
  asOf?: Date;
2503
3364
  }
3365
+ /** One contract still running, and the issuer it was concluded under. */
3366
+ interface RunningContractIssuer {
3367
+ id: string;
3368
+ /** The tenant it was concluded for, kept as a trace. */
3369
+ tenantId: string;
3370
+ /**
3371
+ * The legal name on the issuer copy, or `null` where the contract names no
3372
+ * issuer — it was concluded while `config/saas.yaml` named none, or its
3373
+ * party copy was made by the migration that attached contracts concluded
3374
+ * before subscribers existed.
3375
+ */
3376
+ issuerLegalName: string | null;
3377
+ effectiveFrom: Date;
3378
+ }
3379
+ /** How many contracts are concluded and not yet over, and the first few of them. */
3380
+ interface RunningContractIssuers {
3381
+ /** All of them, whether or not the list below holds them all. */
3382
+ total: number;
3383
+ /** At most the limit the caller asked for, oldest first. */
3384
+ contracts: RunningContractIssuer[];
3385
+ }
2504
3386
  interface InvoiceLineItemSnapshot {
2505
3387
  sourceContractLineItemId: string;
2506
3388
  sourceKey: string;
@@ -2513,12 +3395,14 @@ interface InvoiceLineItemSnapshot {
2513
3395
  priceNet: number;
2514
3396
  priceGross: number;
2515
3397
  billingCycle: 'monthly' | 'yearly';
3398
+ currency: string;
3399
+ taxRate: number;
3400
+ taxAmount: number;
2516
3401
  minimumTermUntil: Date | null;
2517
3402
  metadata: Record<string, unknown> | null;
2518
3403
  }
2519
3404
  interface SubscriptionContractInvoiceSnapshot {
2520
3405
  contractId: string;
2521
- projectKey: string;
2522
3406
  tenantId: string;
2523
3407
  originalOfferId: string | null;
2524
3408
  currency: string;
@@ -2533,6 +3417,100 @@ interface SubscriptionContractInvoiceSnapshot {
2533
3417
  lineItems: InvoiceLineItemSnapshot[];
2534
3418
  }
2535
3419
 
3420
+ /** How a subscriber is reached, which may change at any time. */
3421
+ interface SubscriberContact extends PartyAddress {
3422
+ /** Where invoices will be sent. */
3423
+ invoiceEmail: string | null;
3424
+ }
3425
+ /** A subscriber's master data, as it stands. */
3426
+ type SubscriberDetails = LegalIdentity & SubscriberContact;
3427
+ /**
3428
+ * What a subscriber is created with.
3429
+ *
3430
+ * Only the legal name is required. The address, the tax identifiers and the
3431
+ * invoice email stay optional until sign-up asks for them and invoicing
3432
+ * requires them; an absent or blank value is recorded as unknown.
3433
+ */
3434
+ type NewSubscriberDetails = Pick<SubscriberDetails, 'legalName'> & Partial<Omit<SubscriberDetails, 'legalName'>>;
3435
+ interface SubscriberRecord extends SubscriberDetails {
3436
+ id: string;
3437
+ /**
3438
+ * Assigned when the subscriber is created and never changed: the number,
3439
+ * counted per installation, behind the prefix configured at that moment.
3440
+ */
3441
+ customerNumber: string;
3442
+ /** The tenant this subscriber is live for, or `null` once it has none. */
3443
+ tenantId: string | null;
3444
+ /**
3445
+ * Created by the migration that gave every existing tenant its subscriber,
3446
+ * from the application's own tenant record rather than from what a customer
3447
+ * entered.
3448
+ */
3449
+ migrated: boolean;
3450
+ createdAt: Date;
3451
+ updatedAt: Date;
3452
+ }
3453
+ /** What a repository writes for a new subscriber, every detail already settled. */
3454
+ interface CreateSubscriberData extends SubscriberDetails {
3455
+ /** The tenant the subscriber is created for and live with. */
3456
+ tenantId: string;
3457
+ /** Put in front of the assigned number; empty for the number alone. */
3458
+ customerNumberPrefix: string;
3459
+ }
3460
+ /** Contact details to change; a member left out keeps its value. */
3461
+ type SubscriberContactChange = Partial<SubscriberContact>;
3462
+ /**
3463
+ * A change to a subscriber's legal identity, and what the operator declares it
3464
+ * to be.
3465
+ *
3466
+ * SaaSiCat cannot tell a misspelt name from another company taking over, so the
3467
+ * operator says which: `correction` for the same legal entity — a typo, a wrong
3468
+ * tax identifier, a change of name that entity went through — and `takeover`
3469
+ * for another one, which is a transfer rather than an edit and is refused.
3470
+ */
3471
+ interface SubscriberIdentityCorrection {
3472
+ kind: 'correction' | 'takeover';
3473
+ legalName?: string;
3474
+ vatId?: string | null;
3475
+ taxNumber?: string | null;
3476
+ /** Why the identity is corrected. Part of the record. */
3477
+ reason: string;
3478
+ /** Who corrects it, as an actor tag the audit log would write. */
3479
+ correctedBy: string;
3480
+ }
3481
+ /** Identity values by field, holding only the fields a correction changed. */
3482
+ type SubscriberIdentityValues = Partial<LegalIdentity>;
3483
+ /** What a correction replaces and what it writes, holding only the fields that move. */
3484
+ interface SubscriberIdentityDelta {
3485
+ previous: SubscriberIdentityValues;
3486
+ corrected: SubscriberIdentityValues;
3487
+ }
3488
+ /** What a repository writes for a correction the service has accepted. */
3489
+ interface SubscriberCorrectionData {
3490
+ /** The new values; a field equal to what is stored is not recorded. */
3491
+ corrected: SubscriberIdentityValues;
3492
+ reason: string;
3493
+ correctedBy: string;
3494
+ correctedAt: Date;
3495
+ }
3496
+ /** One correction of a subscriber's legal identity, as it was recorded. */
3497
+ interface SubscriberCorrectionRecord {
3498
+ id: string;
3499
+ subscriberId: string;
3500
+ /** The values the correction replaced. */
3501
+ previous: SubscriberIdentityValues;
3502
+ /** The values it wrote. */
3503
+ corrected: SubscriberIdentityValues;
3504
+ reason: string;
3505
+ correctedBy: string;
3506
+ correctedAt: Date;
3507
+ }
3508
+ /** The outcome of writing a correction: nothing is recorded when no value moved. */
3509
+ interface SubscriberCorrectionResult {
3510
+ subscriber: SubscriberRecord;
3511
+ correction: SubscriberCorrectionRecord | null;
3512
+ }
3513
+
2536
3514
  /**
2537
3515
  * Snapshot form of a `Subscription` row for the EntitlementService
2538
3516
  * computation. The consumer maps its Prisma structure onto this form.
@@ -2552,6 +3530,24 @@ interface SubscriptionRecord {
2552
3530
  } | null;
2553
3531
  planVersionId: string;
2554
3532
  planVersion: PlanVersionRecord;
3533
+ /**
3534
+ * When a cancellation was declared, and when it takes effect.
3535
+ *
3536
+ * Required, and required together, because entitlement resolution ends a
3537
+ * subscription by reading them: without the second date it cannot tell a
3538
+ * subscription that ends next January from one that ended last January, and
3539
+ * it grants the latter everything. Nothing else in the platform would
3540
+ * notice — no repository filters a cancelled subscription out, and stopping
3541
+ * the billing period is a different decision from ending what a tenant may
3542
+ * do.
3543
+ *
3544
+ * `null` on both means no cancellation. On a row written before the two
3545
+ * fields separated, `canceledAt` carries the effective date and
3546
+ * `canceledEffectiveAt` is genuinely null; every reader in the platform
3547
+ * applies `canceledEffectiveAt ?? canceledAt` for that reason.
3548
+ */
3549
+ canceledAt: Date | null;
3550
+ canceledEffectiveAt: Date | null;
2555
3551
  }
2556
3552
  /** Snapshot of a `PlanVersion` row. */
2557
3553
  interface PlanVersionRecord {
@@ -2608,11 +3604,10 @@ interface SubscriptionRepository {
2608
3604
  countByBundleVersionId?(bundleVersionId: string): Promise<number>;
2609
3605
  /**
2610
3606
  * 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`).
3607
+ * platform-wide across every tenant — feeds the tenant column of the
3608
+ * SuperAdmin plan list (`GET /admin/catalog/plans/tenant-counts`).
2613
3609
  * Cross-version: counts the plan, not a single PlanVersion
2614
- * (subscriptions on superseded versions are included). `projectKey` is
2615
- * informational for single-project consumers.
3610
+ * (subscriptions on superseded versions are included).
2616
3611
  *
2617
3612
  * Returns a map `planKey → count`; plans without an active subscription
2618
3613
  * are missing (UI defaults to 0). Platform-wide count across all tenants →
@@ -2620,7 +3615,7 @@ interface SubscriptionRepository {
2620
3615
  *
2621
3616
  * Optional — if not implemented, the tenant column stays 0.
2622
3617
  */
2623
- countActiveByPlanKey?(projectKey: string): Promise<Record<string, number>>;
3618
+ countActiveByPlanKey?(): Promise<Record<string, number>>;
2624
3619
  }
2625
3620
  /**
2626
3621
  * Adapter for the `subscription_bundles` junction.
@@ -2679,8 +3674,89 @@ interface SubscriptionContractRepository {
2679
3674
  * instead of drawing an extra pool connection (starvation guard, #70).
2680
3675
  */
2681
3676
  findActiveByTenantId(tenantId: string, asOf?: Date, tx?: TransactionContext): Promise<SubscriptionContractRecord | null>;
2682
- create(data: CreateSubscriptionContractData): Promise<SubscriptionContractRecord>;
3677
+ /**
3678
+ * Writes the contract with the parties it names. With `tx`, the contract is
3679
+ * written on that transaction and undone with it.
3680
+ */
3681
+ create(data: NewSubscriptionContractData, tx?: TransactionContext): Promise<SubscriptionContractRecord>;
3682
+ /**
3683
+ * The contract concluded from a checkout offer (`originalOfferId`), or
3684
+ * `null` when none was. An offer is consumed once and so yields one
3685
+ * contract (`SC-MKT-017`); where an application's own path wrote two, the
3686
+ * earliest is returned, so the answer does not depend on read order.
3687
+ */
3688
+ findByOriginalOfferId(offerId: string, tx?: TransactionContext): Promise<SubscriptionContractRecord | null>;
2683
3689
  terminate(contractId: string, data: TerminateSubscriptionContractData): Promise<SubscriptionContractRecord>;
3690
+ /**
3691
+ * The contracts concluded and not yet over, with the issuer each was
3692
+ * concluded under: how many there are, and the first `limit` of them,
3693
+ * oldest first. `limit` caps the list and not the count, so a start refused
3694
+ * over a changed issuer identity says how many contracts it means before it
3695
+ * names any of them.
3696
+ *
3697
+ * Running means `active` or `scheduled` AND not ended at `asOf` — the same
3698
+ * window `findActiveByTenantId` uses on its upper end, and for the same
3699
+ * reason. Status alone is not enough: an ordinary cancellation lands at the
3700
+ * term end and writes only `effectiveUntil`, leaving the status where it
3701
+ * was, and nothing flips it when that day arrives. Counting by status would
3702
+ * therefore report every customer who ever left as still running — and
3703
+ * because the list is oldest first, the ones it names would be exactly the
3704
+ * expired ones.
3705
+ *
3706
+ * The window is open at the bottom on purpose: a contract that starts next
3707
+ * month is concluded, its party copy is fixed, and it will be invoiced under
3708
+ * the issuer it names.
3709
+ *
3710
+ * Platform-wide: unlike every other read here it is anchored by no tenant,
3711
+ * no contract and no offer, and a start makes it before anything is served.
3712
+ * An implementation on a tenant-scoped client must count RLS-exempt, as
3713
+ * `countActiveByPlanKey` must — the platform wraps the call in
3714
+ * `RlsBypassPort`, and one that answers with the caller's tenant scope
3715
+ * instead returns nothing at a boot, where there is no tenant. The
3716
+ * persistence contract runs with no policy forced, so it cannot catch that
3717
+ * for you.
3718
+ *
3719
+ * `limit` may be `0`, and a caller that wants only the count passes it:
3720
+ * `total` is exact whatever the limit, so nothing has to come back for it.
3721
+ * Zero means zero — an implementation that reads a falsy limit as "no limit"
3722
+ * returns every running contract to a caller asking for none, which is the
3723
+ * one shape of this method that gets slower the more an installation sells.
3724
+ * The executable contract asks for `0`.
3725
+ */
3726
+ listRunningIssuers(limit: number, asOf?: Date): Promise<RunningContractIssuers>;
3727
+ }
3728
+ /**
3729
+ * The parties contracts are concluded with, their link to the tenant they are
3730
+ * live for, and the corrections of their legal identity.
3731
+ *
3732
+ * A subscriber has at most one live tenant and a tenant at most one live
3733
+ * subscriber; the database holds both, so two callers creating one for the
3734
+ * same tenant at once end with one.
3735
+ */
3736
+ interface SubscriberRepository {
3737
+ /**
3738
+ * Creates a subscriber, assigns its customer number, and makes it the
3739
+ * tenant's live subscriber — all or nothing. `null` when the tenant already
3740
+ * has a live subscriber, in which case nothing is written and the caller's
3741
+ * transaction stays usable.
3742
+ */
3743
+ createForTenant(data: CreateSubscriberData, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3744
+ findById(subscriberId: string, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3745
+ /** The subscriber live for this tenant, or `null` when it has none. */
3746
+ findByTenantId(tenantId: string, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3747
+ /** Writes the members given and keeps the rest; `null` when no such subscriber exists. */
3748
+ updateContact(subscriberId: string, change: SubscriberContactChange, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3749
+ /**
3750
+ * Writes a correction of the legal identity and records it, in one step:
3751
+ * the subscriber is read and changed under a lock, so the values recorded
3752
+ * as replaced are the ones this write replaced even when two corrections
3753
+ * arrive at once. A field whose stored value already equals the corrected
3754
+ * one is left out of the record, and when none differs nothing is written
3755
+ * and `correction` is `null`. `null` when no such subscriber exists.
3756
+ */
3757
+ correctIdentity(subscriberId: string, data: SubscriberCorrectionData, tx?: TransactionContext): Promise<SubscriberCorrectionResult | null>;
3758
+ /** Every correction of this subscriber, the latest first. */
3759
+ listCorrections(subscriberId: string): Promise<SubscriberCorrectionRecord[]>;
2684
3760
  }
2685
3761
  /**
2686
3762
  * Display form of a subscription for the tenant self-service UI.
@@ -2712,6 +3788,40 @@ interface SubscriptionUsageRecord {
2712
3788
  /** Current period window — for proration and change-effective date. */
2713
3789
  currentPeriodStart: Date | null;
2714
3790
  currentPeriodEnd: Date | null;
3791
+ /**
3792
+ * End of what was committed to, which the period end need not equal.
3793
+ *
3794
+ * The cancellation rules measure against this: a subscription cancelled
3795
+ * inside its term keeps running until the term ends, not until the period
3796
+ * does. Null on a trial, and on any subscription written before the field
3797
+ * existed — readers treat that as "the period end is the answer".
3798
+ */
3799
+ minimumTermUntil?: Date | null;
3800
+ /**
3801
+ * The day of the month the subscription is billed on, 1–31.
3802
+ *
3803
+ * Read by the cancellation rules: a declaration after the notice window
3804
+ * lands one period past the term end, and that step has to measure from the
3805
+ * billing day rather than from a term end that may already have been
3806
+ * clamped by a short month.
3807
+ *
3808
+ * Optional, because an adapter that does not store the column keeps today's
3809
+ * behaviour — the step then takes its day from the term end, which is
3810
+ * correct except in the month after a clamp.
3811
+ */
3812
+ billingAnchorDay?: number | null;
3813
+ /**
3814
+ * When a cancellation was declared, and when it lands.
3815
+ *
3816
+ * Required for the reason the same pair is required on
3817
+ * `SubscriptionRecord`: the tenant billing route reads them to refuse a
3818
+ * plan change on a subscription that has ended, and a record that omits
3819
+ * them answers "not cancelled" — so the change is applied and prorated
3820
+ * while entitlement resolution, which reads a record that does carry them,
3821
+ * grants nothing.
3822
+ */
3823
+ canceledAt: Date | null;
3824
+ canceledEffectiveAt: Date | null;
2715
3825
  pendingPlan: string | null;
2716
3826
  pendingBillingCycle: string | null;
2717
3827
  pendingEffectiveAt: Date | null;
@@ -2787,12 +3897,29 @@ interface ImmediatePlanChangeInput {
2787
3897
  * change, or target package without trial). A `Date` is persisted.
2788
3898
  */
2789
3899
  trialEndsAt?: Date | null;
3900
+ /**
3901
+ * `canceledAt` as the caller read it, so the write can claim the row only
3902
+ * while that is still true.
3903
+ *
3904
+ * Three of the plan route's decisions depend on the cancellation — whether
3905
+ * the change is refused at all, whether the billing cycle may move, and
3906
+ * whether a fresh period is opened — and a read and a write are two
3907
+ * moments. A cancellation declared in between made every one of them answer
3908
+ * about a state that no longer existed, and the write went ahead anyway: a
3909
+ * plan term recorded past the date the subscription ends.
3910
+ *
3911
+ * `null` is a value here rather than an absence. It claims a row that has
3912
+ * no cancellation, and loses against one that has acquired one.
3913
+ */
3914
+ expectedCanceledAt: Date | null;
2790
3915
  }
2791
3916
  /** Input for `schedulePlanChange` (change at period end). */
2792
3917
  interface ScheduledPlanChangeInput {
2793
3918
  pendingPlan: string;
2794
3919
  pendingBillingCycle: string;
2795
3920
  pendingEffectiveAt: Date;
3921
+ /** See `ImmediatePlanChangeInput.expectedCanceledAt`. */
3922
+ expectedCanceledAt: Date | null;
2796
3923
  }
2797
3924
  /**
2798
3925
  * Input for `applyOnboardingSelection`. Plan-change fields that the
@@ -2801,6 +3928,12 @@ interface ScheduledPlanChangeInput {
2801
3928
  interface ApplyOnboardingSelectionInput {
2802
3929
  planId: string;
2803
3930
  cycle: string;
3931
+ /**
3932
+ * See `ImmediatePlanChangeInput.expectedCanceledAt`. The atomic path needs
3933
+ * it for the same reason the sequential one does: without it, the preferred
3934
+ * implementation is the one where the race stays open.
3935
+ */
3936
+ expectedCanceledAt: Date | null;
2804
3937
  /** For TRIAL → null, otherwise period start from `initialPeriodWindow`. */
2805
3938
  periodStart: Date | null;
2806
3939
  periodEnd: Date | null;
@@ -2817,6 +3950,12 @@ interface ApplyOnboardingSelectionResult {
2817
3950
  subscriptionId: string;
2818
3951
  /** null if no redeemPromo callback was provided or the callback returned null. */
2819
3952
  promoRedemption: PromoCodeRedemptionRecord | null;
3953
+ /**
3954
+ * False when the row's cancellation moved since the caller read it, in
3955
+ * which case nothing was written — including the promo redemption, which
3956
+ * shares the transaction.
3957
+ */
3958
+ claimed: boolean;
2820
3959
  }
2821
3960
  /**
2822
3961
  * Callback signature for promo-code redemption WITHIN the onboarding
@@ -2834,14 +3973,54 @@ type RedeemPromoInTransactionCallback = (tx: TransactionContext, subscriptionId:
2834
3973
  * app-specific. The platform service calls `invalidateTenant` in the
2835
3974
  * EntitlementService after a successful adapter call.
2836
3975
  */
3976
+ /** What `cancelSubscription` is told to write. Named so both adapters spell
3977
+ * the same shape once rather than each restating it. */
3978
+ interface CancelSubscriptionInput {
3979
+ canceledAt: Date;
3980
+ effectiveAt: Date;
3981
+ terminateNow: boolean;
3982
+ minimumTermUntil?: Date;
3983
+ }
3984
+ /** What `cancelSubscription` answers with. */
3985
+ interface CancelSubscriptionResult {
3986
+ canceledAt: Date | null;
3987
+ canceledEffectiveAt: Date | null;
3988
+ status: string;
3989
+ /**
3990
+ * True when a cancellation was already recorded and this call changed
3991
+ * nothing — the stored dates are returned instead.
3992
+ *
3993
+ * The caller checks first, but a check and a write are two moments, and two
3994
+ * requests can pass the check before either writes. Straddling a notice
3995
+ * deadline that costs a billing cycle: the first declaration lands on time,
3996
+ * the second recomputes against a later `now`, and an unconditional write
3997
+ * replaces the first answer with one a period further out. An
3998
+ * implementation therefore claims the row only while both cancellation
3999
+ * fields are still empty, and answers `true` here when the claim finds
4000
+ * nothing to claim.
4001
+ */
4002
+ alreadyCanceled: boolean;
4003
+ }
2837
4004
  interface TenantSubscriptionWritePort {
4005
+ /**
4006
+ * Whether the writes that change a plan — the immediate change and the
4007
+ * onboarding selection — bind the subscription's `planVersionId` to the
4008
+ * version they sell. A contract freeze records the bound version, so it
4009
+ * refuses to start beside a write that says `false`. Left out, it is taken
4010
+ * on trust: the platform cannot see into a write it did not ship.
4011
+ */
4012
+ readonly bindsPlanVersion?: boolean;
2838
4013
  /** Immediate change: set plan + cycle, clear pending fields, optionally reset the period. */
2839
4014
  changePlanImmediate(tenantId: string, input: ImmediatePlanChangeInput): Promise<{
2840
4015
  plan: string;
2841
4016
  billingCycle: string;
4017
+ /** False when the row's cancellation moved since the caller read it. */
4018
+ claimed: boolean;
2842
4019
  }>;
2843
4020
  /** Change at period end: set pending fields. */
2844
- schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<void>;
4021
+ schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<{
4022
+ claimed: boolean;
4023
+ }>;
2845
4024
  /**
2846
4025
  * Marks the pending PlanVersion as accepted. Idempotent — a duplicate
2847
4026
  * accept is a no-op. Returns `alreadyAccepted: true` if the status was
@@ -2854,13 +4033,30 @@ interface TenantSubscriptionWritePort {
2854
4033
  alreadyAccepted: boolean;
2855
4034
  }>;
2856
4035
  /**
2857
- * Cancel the subscription. `immediate=true` → status CANCELED from now;
2858
- * `false` → canceledAt = currentPeriodEnd, status is preserved.
4036
+ * Record a cancellation. The dates are decided above this port.
4037
+ *
4038
+ * `canceledAt` is when the customer said it; `effectiveAt` is when it
4039
+ * lands. They differ for every ordinary cancellation, because a
4040
+ * subscription cancelled inside its term keeps running, keeps being billed
4041
+ * and keeps its entitlements until the term ends. An adapter that computed
4042
+ * the second from the first — which this one did, as
4043
+ * `immediate ? now : currentPeriodEnd` — was deciding a commercial
4044
+ * question in a persistence layer, and could not see the minimum term or
4045
+ * the notice period at all.
4046
+ *
4047
+ * `terminateNow` flips the status immediately, and is set when the
4048
+ * cancellation is already effective: an operator ending a contract, or the
4049
+ * rules finding nothing left to run — no period, no term, as on a trial.
4050
+ * It is never a client's request. A tenant may always declare a
4051
+ * cancellation and may never shorten the term they are in; what decides
4052
+ * this flag is the date the rules returned, not the date they asked for.
4053
+ *
4054
+ * `minimumTermUntil` extends the stored commitment, and is set only when
4055
+ * the cancellation itself extends it: a declaration made after the notice
4056
+ * deadline buys the following period. Left unset the stored term end is
4057
+ * unchanged, which is the ordinary case.
2859
4058
  */
2860
- cancelSubscription(tenantId: string, immediate: boolean, now: Date): Promise<{
2861
- canceledAt: Date | null;
2862
- status: string;
2863
- }>;
4059
+ cancelSubscription(tenantId: string, input: CancelSubscriptionInput): Promise<CancelSubscriptionResult>;
2864
4060
  /**
2865
4061
  * Atomic onboarding creation: sets plan + cycle + period window
2866
4062
  * AND optionally calls a promo-redeem callback — all in a
@@ -2883,6 +4079,8 @@ interface PlanVersionRepository {
2883
4079
  *
2884
4080
  * Note: ignores `validFrom`/`validUntil`. For time-aware
2885
4081
  * resolution (onboarding, plan fallback for TRIAL) use `findActive`.
4082
+ *
4083
+ * A plan key no plan has finds `null`, not an error.
2886
4084
  */
2887
4085
  findLatestLive(planId: string, tx?: TransactionContext): Promise<PlanVersionRecord | null>;
2888
4086
  /**
@@ -2895,6 +4093,8 @@ interface PlanVersionRepository {
2895
4093
  * return the highest `validFrom`, explicitly ordering null start dates
2896
4094
  * last as a legacy fallback. Adapters without validity columns may omit
2897
4095
  * the method (consumers fall back to `findLatestLive`).
4096
+ *
4097
+ * A plan key no plan has finds `null`, not an error.
2898
4098
  */
2899
4099
  findActive?(planId: string, asOf?: Date, tx?: TransactionContext): Promise<PlanVersionRecord | null>;
2900
4100
  }
@@ -3088,23 +4288,31 @@ interface AdminResourcesPort {
3088
4288
  }
3089
4289
  /**
3090
4290
  * 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.
4291
+ * delegates to its `AdminManifestService.getManifest()`, which reads the plan
4292
+ * catalogue on every call and therefore answers asynchronously. The platform
4293
+ * CLI uses this for `<app> manifest dump|hash|check` etc.
3093
4294
  */
3094
4295
  interface ManifestAccessPort {
3095
- getManifest(): AdminManifest;
4296
+ getManifest(): Promise<AdminManifest>;
3096
4297
  /** Optional: forces a rebuild from the contributions (e.g. after code reload). */
3097
- rebuild?(): AdminManifest;
4298
+ rebuild?(): Promise<AdminManifest>;
3098
4299
  }
3099
4300
  /**
3100
4301
  * Adapter for the RLS bypass context. Platform code calls `runWithBypass`,
3101
4302
  * the consumer implementation triggers the Postgres session variable
3102
4303
  * (`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.).
4304
+ * Django/other stacks. The execution context lives for the call it wraps
4305
+ * (AsyncLocalStorage / `contextvars` etc.).
3105
4306
  *
3106
4307
  * SuperAdmin operations are platform-wide without tenant scope — without
3107
4308
  * bypass, all RLS-protected reads would come back empty.
4309
+ *
4310
+ * **Not only inside a request.** The platform's boot checks read platform-wide
4311
+ * before anything is served: an implementation that reaches for a
4312
+ * request-scoped connection, or that asserts a request context is open, fails
4313
+ * at start rather than where it is called. Wrap the call the way it is given —
4314
+ * `AsyncLocalStorage.run` is the shape both shipped adapters use, and it is as
4315
+ * good at boot as it is in a request.
3108
4316
  */
3109
4317
  interface RlsBypassPort {
3110
4318
  runWithBypass<T>(fn: () => Promise<T>): Promise<T>;
@@ -3112,7 +4320,6 @@ interface RlsBypassPort {
3112
4320
 
3113
4321
  /** Filter for `PlanRepository.list()`. */
3114
4322
  interface PlanListFilter {
3115
- projectKey: string;
3116
4323
  /** Exclude soft-deleted plans — default `true`. */
3117
4324
  excludeDeleted?: boolean;
3118
4325
  /**
@@ -3146,7 +4353,7 @@ interface PlanListFilter {
3146
4353
  interface PlanRepository {
3147
4354
  list(filter: PlanListFilter): Promise<PlanRow[]>;
3148
4355
  findById(planId: string): Promise<PlanRow | null>;
3149
- findByKey(projectKey: string, planKey: string): Promise<PlanRow | null>;
4356
+ findByKey(planKey: string): Promise<PlanRow | null>;
3150
4357
  create(data: CreatePlanData): Promise<PlanRow>;
3151
4358
  update(planId: string, data: UpdatePlanData): Promise<PlanRow>;
3152
4359
  /** Sets `deletedAt` to NOW(); soft-deleted plans are filtered from `list` by default. */
@@ -3247,10 +4454,24 @@ interface PlanRepository {
3247
4454
  }
3248
4455
  /** Filter for `BundleRepository.list()`. */
3249
4456
  interface BundleListFilter {
3250
- projectKey: string;
3251
4457
  /** Exclude soft-deleted bundles — default `true`. */
3252
4458
  excludeDeleted?: boolean;
3253
4459
  }
4460
+ /**
4461
+ * What publishing a bundle draft records.
4462
+ *
4463
+ * Named because it was written out three times — the port, and each adapter's
4464
+ * implementation of it — and a signature restated is a contract restated: the
4465
+ * copies can drift, and nothing but a reader would notice.
4466
+ */
4467
+ interface PublishBundleVersionMeta {
4468
+ publishedByUserId: string | null;
4469
+ publishedChanges: VersionChange[];
4470
+ nonRegressive: boolean;
4471
+ /** Required — validated by the service before the repository call. */
4472
+ validFrom: Date;
4473
+ validUntil: Date | null;
4474
+ }
3254
4475
  /**
3255
4476
  * Adapter for `Bundle` + `BundleVersion` persistence. Consumers implement
3256
4477
  * this against their Prisma tables (`bundles` + `bundle_versions`).
@@ -3269,7 +4490,7 @@ interface BundleListFilter {
3269
4490
  interface BundleRepository {
3270
4491
  list(filter: BundleListFilter): Promise<BundleRow[]>;
3271
4492
  findById(bundleId: string): Promise<BundleRow | null>;
3272
- findByKey(projectKey: string, bundleKey: string): Promise<BundleRow | null>;
4493
+ findByKey(bundleKey: string): Promise<BundleRow | null>;
3273
4494
  create(data: CreateBundleData): Promise<BundleRow>;
3274
4495
  update(bundleId: string, data: UpdateBundleData): Promise<BundleRow>;
3275
4496
  /** Sets `deletedAt` to NOW(); soft-deleted bundles are filtered from `list` by default. */
@@ -3328,14 +4549,7 @@ interface BundleRepository {
3328
4549
  * if the predecessor carries a `validUntil` — the adapter only
3329
4550
  * persists, it does not validate again.
3330
4551
  */
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>;
4552
+ publishDraft(versionId: string, publishMeta: PublishBundleVersionMeta, tx?: TransactionContext): Promise<BundleVersionRow>;
3339
4553
  /**
3340
4554
  * Hard-discards a draft version (`publishedAt === null`) from the DB.
3341
4555
  * Throws if the version was already published — published versions
@@ -3373,7 +4587,6 @@ interface MarketingProjectionRepository {
3373
4587
  }
3374
4588
  /** Upsert input for a capability from the discovery sync. */
3375
4589
  interface UpsertCapabilityEntryData {
3376
- projectKey: string;
3377
4590
  capabilityKey: string;
3378
4591
  label: string;
3379
4592
  description: string | null;
@@ -3390,7 +4603,6 @@ interface UpsertCapabilityEntryData {
3390
4603
  }
3391
4604
  /** Upsert input for a feature from the discovery sync. */
3392
4605
  interface UpsertFeatureEntryData {
3393
- projectKey: string;
3394
4606
  featureKey: string;
3395
4607
  label: string;
3396
4608
  description: string | null;
@@ -3404,7 +4616,6 @@ interface UpsertFeatureEntryData {
3404
4616
  }
3405
4617
  /** Upsert input for a quota from the discovery sync. */
3406
4618
  interface UpsertQuotaEntryData {
3407
- projectKey: string;
3408
4619
  quotaKey: string;
3409
4620
  label: string;
3410
4621
  description: string | null;
@@ -3434,7 +4645,7 @@ interface SetCatalogEntryReviewData {
3434
4645
  * Prisma tables.
3435
4646
  *
3436
4647
  * Binding:
3437
- * - `upsert*` matches on (`projectKey`, `<key>`) and leaves `i18n`,
4648
+ * - `upsert*` matches on `<key>` and leaves `i18n`,
3438
4649
  * `sortOrder`, `createdAt` as well as the approval fields (`approvedAt`/
3439
4650
  * `approvedBy`/`approvedSignature`) **untouched** on an update —
3440
4651
  * only the code-derived fields + the status (resolved by the service)
@@ -3450,7 +4661,7 @@ interface CatalogEntryRepository {
3450
4661
  upsertCapability(data: UpsertCapabilityEntryData): Promise<CapabilityCatalogEntryRow>;
3451
4662
  upsertFeature(data: UpsertFeatureEntryData): Promise<FeatureCatalogEntryRow>;
3452
4663
  upsertQuota(data: UpsertQuotaEntryData): Promise<QuotaCatalogEntryRow>;
3453
- retireMissing(projectKey: string, type: 'capability' | 'feature' | 'quota', presentKeys: string[]): Promise<number>;
4664
+ retireMissing(type: 'capability' | 'feature' | 'quota', presentKeys: string[]): Promise<number>;
3454
4665
  /**
3455
4666
  * Sets or clears the successor pointer of a feature/quota
3456
4667
  * (#39). The sync calls this when a key disappears from the snapshot
@@ -3459,17 +4670,17 @@ interface CatalogEntryRepository {
3459
4670
  * adapters without a `successor_key` column omit the methods, and the sync
3460
4671
  * then skips the pointers with a warn log.
3461
4672
  */
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>;
4673
+ setFeatureSuccessor?(featureKey: string, successorKey: string | null): Promise<FeatureCatalogEntryRow>;
4674
+ setQuotaSuccessor?(quotaKey: string, successorKey: string | null): Promise<QuotaCatalogEntryRow>;
4675
+ findFeature(featureKey: string): Promise<FeatureCatalogEntryRow | null>;
4676
+ findQuota(quotaKey: string): Promise<QuotaCatalogEntryRow | null>;
4677
+ setFeatureReview(featureKey: string, data: SetCatalogEntryReviewData): Promise<FeatureCatalogEntryRow>;
4678
+ setQuotaReview(quotaKey: string, data: SetCatalogEntryReviewData): Promise<QuotaCatalogEntryRow>;
4679
+ setFeatureI18n(featureKey: string, i18n: CatalogEntryI18n): Promise<FeatureCatalogEntryRow>;
4680
+ setQuotaI18n(quotaKey: string, i18n: CatalogEntryI18n): Promise<QuotaCatalogEntryRow>;
3470
4681
  /** 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>;
4682
+ setFeatureBase(featureKey: string, data: UpdateCatalogEntryBaseData): Promise<FeatureCatalogEntryRow>;
4683
+ setQuotaBase(quotaKey: string, data: UpdateCatalogEntryBaseData): Promise<QuotaCatalogEntryRow>;
3473
4684
  }
3474
4685
  /**
3475
4686
  * Adapter for `promotions`. **No versioning** — promotions are edited
@@ -3477,7 +4688,7 @@ interface CatalogEntryRepository {
3477
4688
  * this against their `promotions` Prisma table.
3478
4689
  */
3479
4690
  interface PromotionRepository {
3480
- list(filter: PromotionFilter): Promise<PromotionRow[]>;
4691
+ list(): Promise<PromotionRow[]>;
3481
4692
  findById(id: string): Promise<PromotionRow | null>;
3482
4693
  create(data: CreatePromotionData): Promise<PromotionRow>;
3483
4694
  update(id: string, data: UpdatePromotionData): Promise<PromotionRow>;
@@ -3485,27 +4696,322 @@ interface PromotionRepository {
3485
4696
  delete(id: string): Promise<void>;
3486
4697
  }
3487
4698
  /**
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.
4699
+ * Adapter for `marketing_settings` — at most one row, which a `CHECK` on the
4700
+ * canonical schema holds rather than convention. `get` returns `null` as long
4701
+ * as the SuperAdmin has saved nothing (then the full `availableLocales` pool
4702
+ * counts as active). `upsert` creates the row or replaces it.
3491
4703
  */
3492
4704
  interface MarketingSettingsRepository {
3493
- get(projectKey: string): Promise<MarketingSettingsRow | null>;
3494
- upsert(projectKey: string, data: UpdateMarketingSettingsData): Promise<MarketingSettingsRow>;
4705
+ get(): Promise<MarketingSettingsRow | null>;
4706
+ upsert(data: UpdateMarketingSettingsData): Promise<MarketingSettingsRow>;
4707
+ }
4708
+
4709
+ /**
4710
+ * Adapter for `checkout_offers`. The offer is an immutable bundle snapshot:
4711
+ * `create` creates it, `update` only allows customization while
4712
+ * `status = 'open'`, `consume` freezes it.
4713
+ */
4714
+ interface CheckoutOfferRepository {
4715
+ list(filter: CheckoutOfferFilter): Promise<CheckoutOfferRow[]>;
4716
+ findById(id: string): Promise<CheckoutOfferRow | null>;
4717
+ create(data: CreateCheckoutOfferData): Promise<CheckoutOfferRow>;
4718
+ update(id: string, data: UpdateCheckoutOfferData): Promise<CheckoutOfferRow>;
4719
+ /**
4720
+ * Sets `status = 'consumed'` + `consumedAt = NOW()`, and only while the
4721
+ * offer is still `open`: the write carries that condition, so of two
4722
+ * callers consuming at once one succeeds and the other is refused with
4723
+ * `CHECKOUT_OFFER_ALREADY_CONSUMED` (or `CHECKOUT_OFFER_EXPIRED`). A check
4724
+ * before the write cannot decide it, because the status can change in
4725
+ * between.
4726
+ *
4727
+ * With `tx`, the write runs on that transaction, so it is undone with
4728
+ * everything else the transaction wrote. `CheckoutOfferService.conclude`
4729
+ * depends on that; the persistence contract holds both.
4730
+ */
4731
+ consume(id: string, tx?: TransactionContext): Promise<CheckoutOfferRow>;
4732
+ }
4733
+
4734
+ /** `ACTIVE` is the one in use; `REPLACED` is one a later payment method took over from. */
4735
+ type SubscriberPaymentMethodStatus = 'ACTIVE' | 'REPLACED';
4736
+ interface SubscriberPaymentMethodRecord extends ConfirmedPaymentMethod {
4737
+ id: string;
4738
+ subscriberId: string;
4739
+ /** The account in `config/saas.yaml#payments.accounts` the references belong to. */
4740
+ gatewayAccount: string;
4741
+ /** The provider of that account when the payment method was confirmed. */
4742
+ provider: string;
4743
+ status: SubscriberPaymentMethodStatus;
4744
+ /** When the gateway confirmed the payment method. */
4745
+ confirmedAt: Date;
4746
+ /** When a later payment method took over; `null` while this one is in use. */
4747
+ replacedAt: Date | null;
4748
+ createdAt: Date;
4749
+ }
4750
+ /** What a repository records for a payment method the gateway confirmed. */
4751
+ interface RecordSubscriberPaymentMethodData extends ConfirmedPaymentMethod {
4752
+ subscriberId: string;
4753
+ gatewayAccount: string;
4754
+ provider: string;
4755
+ confirmedAt: Date;
4756
+ }
4757
+ /**
4758
+ * - `activated`: it is the subscriber's payment method now, and the one it
4759
+ * replaced, if any, is `REPLACED`.
4760
+ * - `already-recorded`: the account's reference was recorded before; nothing
4761
+ * was written, and `method` is the row that holds it.
4762
+ * - `superseded`: the subscriber's payment method in use was confirmed after
4763
+ * this one, so this one is recorded as already replaced — confirmations can
4764
+ * arrive in another order than the forms were filled in.
4765
+ */
4766
+ type RecordSubscriberPaymentMethodOutcome = 'activated' | 'already-recorded' | 'superseded';
4767
+ /**
4768
+ * A change of payment method a tenant started: the gateway session it opened
4769
+ * for the subscriber. A confirmation is recorded only against the setup it
4770
+ * belongs to, so a callback that names another subscriber than the session was
4771
+ * opened for changes nobody's payment method.
4772
+ */
4773
+ interface SubscriberPaymentMethodSetupData {
4774
+ subscriberId: string;
4775
+ gatewayAccount: string;
4776
+ /** The gateway's session, unique within the account. */
4777
+ sessionRef: string;
4778
+ /** The customer the gateway keeps the payment method under. */
4779
+ customerRef: string;
4780
+ startedAt: Date;
4781
+ }
4782
+ /** Which setup a confirmation claims to complete. */
4783
+ interface SubscriberPaymentMethodSetupMatch {
4784
+ gatewayAccount: string;
4785
+ sessionRef: string;
4786
+ subscriberId: string;
4787
+ }
4788
+ /**
4789
+ * Which payment method a read by reference asks about.
4790
+ *
4791
+ * All three together, because `(gatewayAccount, paymentMethodRef)` is unique
4792
+ * account-wide rather than per subscriber: without the subscriber the question
4793
+ * has no answer that is this caller's.
4794
+ *
4795
+ * One argument rather than three strings in a row, for the reason
4796
+ * `SubscriberPaymentMethodSetupMatch` is one — at a call site three strings of
4797
+ * the same type can be swapped with nothing saying so — and for a second that
4798
+ * is specific to a port. `TransactionContext` is `unknown`, so it accepts a
4799
+ * string: against a positional signature, an implementation whose parameters
4800
+ * are offset by one typechecks and then reads the account out of the
4801
+ * subscriber's place. Against an object it does not.
4802
+ */
4803
+ interface SubscriberPaymentMethodReference {
4804
+ subscriberId: string;
4805
+ gatewayAccount: string;
4806
+ paymentMethodRef: string;
4807
+ }
4808
+ interface RecordSubscriberPaymentMethodResult {
4809
+ method: SubscriberPaymentMethodRecord;
4810
+ outcome: RecordSubscriberPaymentMethodOutcome;
4811
+ }
4812
+
4813
+ /** A gateway event, as the log records it. */
4814
+ interface PaymentEventClaim {
4815
+ /** The account the event came from; an event identifier is unique only within it. */
4816
+ gatewayAccount: string;
4817
+ eventId: string;
4818
+ provider: string;
4819
+ /** The gateway session the event is about, where it is about one. */
4820
+ sessionId: string | null;
4821
+ kind: PaymentGatewayEvent['kind'];
4822
+ /** What the event said, without anything that names or reaches a person. */
4823
+ summary: Record<string, unknown>;
4824
+ }
4825
+ /**
4826
+ * The events a gateway sent, each handled once.
4827
+ *
4828
+ * Gateways deliver at least once, so the same event arrives again after a
4829
+ * timeout or a retry. An event is claimed on the transaction that writes what
4830
+ * it changes: when that transaction rolls back, the claim goes with it and the
4831
+ * gateway's retry is handled rather than discarded as a duplicate.
4832
+ */
4833
+ interface PaymentEventLog {
4834
+ /**
4835
+ * Records the event and returns `true`, or returns `false` when this account
4836
+ * already recorded it, writing nothing.
4837
+ *
4838
+ * The duplicate must not raise: on PostgreSQL an error aborts the
4839
+ * transaction it happened on, and the caller's transaction has to stay
4840
+ * usable. An `INSERT … ON CONFLICT DO NOTHING` does both — and when another
4841
+ * transaction holds the same claim uncommitted, it waits for that one and
4842
+ * answers by its outcome.
4843
+ *
4844
+ * A confirmation of a session this account already confirmed is a duplicate
4845
+ * as well, whatever its `eventId`: one session is set up once, and a gateway
4846
+ * may report it through more than one event. `sql/constraints.postgres.sql`
4847
+ * holds that as a second unique index, and the untargeted `DO NOTHING`
4848
+ * answers for it too.
4849
+ */
4850
+ claim(claim: PaymentEventClaim, tx: TransactionContext): Promise<boolean>;
4851
+ /**
4852
+ * Takes the session off a claim whose event changed nothing, so the next
4853
+ * event about that session is handled instead of taken for the duplicate it
4854
+ * is not. The claim itself stays: that one event is never handled twice.
4855
+ *
4856
+ * The session is held from the claim onwards, which is what keeps two
4857
+ * confirmations delivered at once from both taking effect; an event that
4858
+ * turned out to have nothing to do gives it back on the same transaction.
4859
+ */
4860
+ releaseSession(gatewayAccount: string, eventId: string, tx: TransactionContext): Promise<void>;
4861
+ }
4862
+ /**
4863
+ * The payment methods of subscribers, as references at the gateway accounts
4864
+ * that confirmed them.
4865
+ *
4866
+ * A subscriber has at most one `ACTIVE` payment method; the database holds
4867
+ * that, so two confirmations recorded at once for one subscriber end with one.
4868
+ *
4869
+ * **Within an account, a reference belongs to exactly one subscriber**, and
4870
+ * keeps belonging to it once a newer payment method has replaced it. Every
4871
+ * method that reaches a payment method therefore names the subscriber it is
4872
+ * about, and an implementation answers about that one only. `accountsInUse` is
4873
+ * the exception and says so at itself: it counts accounts rather than handing
4874
+ * out a row.
4875
+ *
4876
+ * That matters on a gateway callback, which is where these are reached: it
4877
+ * arrives without a session, so an installation that keeps its tenants apart
4878
+ * with a policy lifts that policy for it, and what the question names is then
4879
+ * all that bounds it.
4880
+ */
4881
+ interface SubscriberPaymentMethodRepository {
4882
+ /**
4883
+ * Records a confirmed payment method for its subscriber, under a lock on the
4884
+ * subscriber so two confirmations take turns. See
4885
+ * `RecordSubscriberPaymentMethodOutcome` for the three outcomes.
4886
+ *
4887
+ * A confirmation carrying a reference **another** subscriber holds is a
4888
+ * fourth case and has no outcome: it is refused with
4889
+ * `ForeignPaymentMethodReferenceError`, because the account's reference is
4890
+ * that subscriber's and answering `already-recorded` would hand its payment
4891
+ * method to a caller acting for somebody else.
4892
+ *
4893
+ * Two subscribers can reach that reference at the same time: the lock is on
4894
+ * the subscriber, so confirmations for two of them do not take turns, and a
4895
+ * read sees nothing of a row the other has not committed. Reading is
4896
+ * therefore not enough to decide it. **Claim the reference with the first
4897
+ * write, conflict-free** — `ON CONFLICT DO NOTHING` on its unique key, or
4898
+ * whatever the store spells that as — and refuse when the claim takes no
4899
+ * row. Both shipped adapters do exactly that, and it is what lets the
4900
+ * refusal say the same thing everywhere: the key decides, not an error
4901
+ * whose shape belongs to one driver.
4902
+ *
4903
+ * The order is part of the contract, not an implementation detail. Because
4904
+ * the claim is the first write, a refusal leaves the caller's transaction
4905
+ * as it found it — nothing to undo, and nothing that makes it unusable.
4906
+ * An implementation that writes before it claims cannot promise that.
4907
+ */
4908
+ recordConfirmed(data: RecordSubscriberPaymentMethodData, tx?: TransactionContext): Promise<RecordSubscriberPaymentMethodResult>;
4909
+ /** The subscriber's payment method in use, or `null` when it has none. */
4910
+ findActive(subscriberId: string, tx?: TransactionContext): Promise<SubscriberPaymentMethodRecord | null>;
4911
+ /**
4912
+ * The payment method the reference names, whatever its status — and `null`
4913
+ * when that subscriber holds none under it, which includes the case of
4914
+ * another subscriber holding the reference.
4915
+ *
4916
+ * The one read that reaches a payment method a newer one replaced, which is
4917
+ * how `@saasicat/persistence-testing` verifies that an implementation keeps
4918
+ * the history rather than overwriting the row (`SC-PRIC-030`).
4919
+ *
4920
+ * The subscriber is what bounds the read. Without it an implementation
4921
+ * would have only the account's reference to go on, which is unique
4922
+ * account-wide: inside a tenant's context a policy would answer `null` and
4923
+ * on a gateway callback, where that policy is lifted, the same call would
4924
+ * answer with somebody else's row. One question, two answers by where it
4925
+ * was asked, is what naming the subscriber removes.
4926
+ */
4927
+ findByReference(reference: SubscriberPaymentMethodReference, tx?: TransactionContext): Promise<SubscriberPaymentMethodRecord | null>;
4928
+ /**
4929
+ * Every account that holds a payment method in use, each once.
4930
+ *
4931
+ * Platform-wide: anchored by no subscriber and no tenant, and a start makes
4932
+ * it before anything is served, to refuse a configuration that no longer
4933
+ * names an account somebody's payment method is held at. An implementation
4934
+ * on a tenant-scoped client must read RLS-exempt — the platform wraps the
4935
+ * call in `RlsBypassPort`, and one that answers with the caller's tenant
4936
+ * scope instead returns an empty list at a boot, where there is no tenant,
4937
+ * so the check that exists to refuse passes. The persistence contract runs
4938
+ * with no policy forced, so it cannot catch that for you.
4939
+ */
4940
+ accountsInUse(): Promise<string[]>;
4941
+ /**
4942
+ * Records a change of payment method a tenant started, open until its
4943
+ * confirmation completes it.
4944
+ *
4945
+ * One session is one setup: the account and the session are unique together,
4946
+ * and a second setup for a session already recorded raises. A gateway hands
4947
+ * out a session per start, so an adapter that returns one twice is the
4948
+ * defect this refuses to write over.
4949
+ */
4950
+ recordSetup(data: SubscriberPaymentMethodSetupData, tx?: TransactionContext): Promise<void>;
4951
+ /**
4952
+ * Completes the open setup the account, the session and the subscriber all
4953
+ * name, and returns `true` — or returns `false`, writing nothing, when no
4954
+ * open setup matches all three: none was started, it was started for another
4955
+ * subscriber, or it is complete already. A single conditional write, so two
4956
+ * confirmations for one setup complete it once.
4957
+ */
4958
+ completeSetup(match: SubscriberPaymentMethodSetupMatch, completedAt: Date, tx?: TransactionContext): Promise<boolean>;
3495
4959
  }
3496
4960
 
4961
+ /** Which changes to list. */
4962
+ interface SettingsChangeFilter {
4963
+ /** Only changes an operator has, or has not, acknowledged. Omitted: both. */
4964
+ acknowledged?: boolean;
4965
+ /** The most recently recorded ones. Omitted: every matching change. */
4966
+ limit?: number;
4967
+ }
3497
4968
  /**
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>;
4969
+ * Stores what the installation applied and what changed between two boots.
4970
+ *
4971
+ * `applied_settings` holds one row for the installation; `settings_changes`
4972
+ * holds one row per boot that found the fingerprint moved. An adapter
4973
+ * translates: it does not decide what a change is, and it does not read the
4974
+ * row back into anything that runs.
4975
+ *
4976
+ * Both writes are guarded on the fingerprint the caller read. Several replicas
4977
+ * of one installation start together after one edit of the file, each reads
4978
+ * the same record and each finds the same difference; the guard is what makes
4979
+ * one of them the boot that recorded it and the others boots that found it
4980
+ * recorded. Without it every replica would write the change and mail the
4981
+ * addresses, once per replica.
4982
+ */
4983
+ interface AppliedSettingsPort {
4984
+ /** The record, or null before the first boot that could write one. */
4985
+ readApplied(): Promise<AppliedSettingsRecord | null>;
4986
+ /**
4987
+ * Replaces the installation's record — there is only ever the one row —
4988
+ * provided the stored record still carries `expectedFingerprint`: the
4989
+ * fingerprint the caller read, or `null` where it read no record. Returns
4990
+ * whether it did. `false` means the record moved between the caller's read
4991
+ * and this write, and nothing was written: another boot got there first.
4992
+ */
4993
+ writeApplied(record: AppliedSettingsRecord, expectedFingerprint: string | null): Promise<boolean>;
4994
+ /**
4995
+ * Appends a change a boot noticed and replaces the record it supersedes, in
4996
+ * one step: both land, or neither does. Guarded like `writeApplied`, on the
4997
+ * fingerprint of the record the change was noticed against. Returns the
4998
+ * change as stored — the id is the adapter's to assign — or `null` where
4999
+ * the record had already moved on: another boot noticed first, and the
5000
+ * change is that boot's to report.
5001
+ */
5002
+ recordChange(change: NewSettingsChange, record: AppliedSettingsRecord, expectedFingerprint: string): Promise<SettingsChangeRecord | null>;
5003
+ /**
5004
+ * Changes, the most recently recorded first: the order the record went
5005
+ * through them, which the database numbers at each write — not the order
5006
+ * of the moments they carry, which are the recording starts' clocks.
5007
+ */
5008
+ listChanges(filter?: SettingsChangeFilter): Promise<SettingsChangeRecord[]>;
5009
+ /**
5010
+ * Marks a change as seen. Returns the updated record, or null where no
5011
+ * change has that id. A change already acknowledged keeps its first
5012
+ * acknowledgement — repeating the action changes nothing.
5013
+ */
5014
+ acknowledgeChange(id: string, acknowledgedBy: string, acknowledgedAt: Date): Promise<SettingsChangeRecord | null>;
3509
5015
  }
3510
5016
 
3511
5017
  /** Class reference usable as a DI token (e.g. the consumer's `PrismaService`). */
@@ -3565,12 +5071,20 @@ interface SaaSiCatPersistenceCore {
3565
5071
  auditQuery?: PersistenceProvider<AuditQueryPort>;
3566
5072
  /** Aggregation for the admin stats dashboard. */
3567
5073
  auditStats?: PersistenceProvider<AuditStatsPort>;
5074
+ /**
5075
+ * The record of the applied configuration (`SettingsModule`). Optional so
5076
+ * an adapter written before it existed keeps working; without it the
5077
+ * platform says once at boot that it is not recording.
5078
+ */
5079
+ appliedSettings?: PersistenceProvider<AppliedSettingsPort>;
3568
5080
  }
3569
5081
  /** Repositories for the entitlement/contract loop (`EntitlementModule`). */
3570
5082
  interface SaaSiCatPersistenceEntitlement {
3571
5083
  subscriptionRepository: PersistenceProvider<SubscriptionRepository>;
3572
5084
  planVersionRepository: PersistenceProvider<PlanVersionRepository>;
3573
5085
  subscriptionContractRepository?: PersistenceProvider<SubscriptionContractRepository>;
5086
+ /** The parties contracts are concluded with; required wherever contracts are written. */
5087
+ subscriberRepository?: PersistenceProvider<SubscriberRepository>;
3574
5088
  subscriptionBundleRepository?: PersistenceProvider<SubscriptionBundleRepository>;
3575
5089
  bundleRepository?: PersistenceProvider<BundleRepository>;
3576
5090
  }
@@ -3597,6 +5111,15 @@ interface SaaSiCatPersistenceTenantBilling {
3597
5111
  subscriptionWritePort: PersistenceProvider<TenantSubscriptionWritePort>;
3598
5112
  usageSnapshotPort?: PersistenceProvider<UsageSnapshotPort>;
3599
5113
  }
5114
+ /**
5115
+ * The record of payment gateway callbacks and the payment methods they confirm
5116
+ * (`payments` in `SaaSiCatModule.forRoot`). Both are written on the transaction
5117
+ * a callback is handled on, so they come from the adapter that runs it.
5118
+ */
5119
+ interface SaaSiCatPersistencePayments {
5120
+ paymentEventLog: PersistenceProvider<PaymentEventLog>;
5121
+ subscriberPaymentMethodRepository: PersistenceProvider<SubscriberPaymentMethodRepository>;
5122
+ }
3600
5123
  /** Read/write backing for the standard SuperAdmin resource pages. */
3601
5124
  interface SaaSiCatPersistenceAdminResources {
3602
5125
  resources: PersistenceProvider<AdminResourcesPort>;
@@ -3613,6 +5136,8 @@ interface SaaSiCatPersistencePromo {
3613
5136
  validationLogRepository: PersistenceProvider<PromoCodeValidationLogRepository>;
3614
5137
  subscriptionLookup: PersistenceProvider<PromoSubscriptionLookup>;
3615
5138
  revenueAggregator: PersistenceProvider<PromoRevenueDeductionAggregator>;
5139
+ /** The slots a code keeps for checkouts; left out, no checkout holds one. */
5140
+ holdRepository?: PersistenceProvider<PromoCodeHoldRepository>;
3616
5141
  }
3617
5142
  /**
3618
5143
  * Aggregate persistence bundle. Produced by adapter factories such as
@@ -3631,6 +5156,8 @@ interface SaaSiCatPersistenceAdapter {
3631
5156
  /** Tenant, user, audit and subscription resources for the SuperAdmin UI. */
3632
5157
  adminResources?: SaaSiCatPersistenceAdminResources;
3633
5158
  promo?: SaaSiCatPersistencePromo;
5159
+ /** Payment gateway callbacks and subscriber payment methods. */
5160
+ payments?: SaaSiCatPersistencePayments;
3634
5161
  /** DB hydration of the plan catalog at boot (`PlanCatalogModule`). */
3635
5162
  planCatalogReadSink?: PersistenceProvider<PlanCatalogReadSink>;
3636
5163
  /** One-shot `saas.yaml → DB` import. */
@@ -3703,6 +5230,8 @@ declare const CATALOG_ERROR_CODES: {
3703
5230
  readonly BUNDLE_VERSION_SUPERSEDED: "BUNDLE_VERSION_SUPERSEDED";
3704
5231
  readonly BUNDLE_VERSION_REGRESSION: "BUNDLE_VERSION_REGRESSION";
3705
5232
  readonly BUNDLE_VERSION_ZERO_PRICE: "BUNDLE_VERSION_ZERO_PRICE";
5233
+ readonly BUNDLE_VERSION_NO_PRICE: "BUNDLE_VERSION_NO_PRICE";
5234
+ readonly BUNDLE_VERSION_NOT_PRICED_FOR_PLAN: "BUNDLE_VERSION_NOT_PRICED_FOR_PLAN";
3706
5235
  readonly BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED: "BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED";
3707
5236
  readonly BUNDLE_VERSION_VALID_FROM_REQUIRED: "BUNDLE_VERSION_VALID_FROM_REQUIRED";
3708
5237
  readonly BUNDLE_VERSION_VALID_FROM_INVALID: "BUNDLE_VERSION_VALID_FROM_INVALID";
@@ -3720,6 +5249,12 @@ declare const CATALOG_ERROR_CODES: {
3720
5249
  readonly FEATURE_NOT_FOUND: "FEATURE_NOT_FOUND";
3721
5250
  readonly QUOTA_NOT_FOUND: "QUOTA_NOT_FOUND";
3722
5251
  readonly PROMOTION_NOT_FOUND: "PROMOTION_NOT_FOUND";
5252
+ /**
5253
+ * A promotion's value is not one its type takes: a percentage above 0 and
5254
+ * at most 100, an amount above 0, an intro price of at least 0 for a whole
5255
+ * number of months, or a whole number of free months.
5256
+ */
5257
+ readonly PROMOTION_VALUE_INVALID: "PROMOTION_VALUE_INVALID";
3723
5258
  readonly MARKETING_PROJECTION_NOT_FOUND: "MARKETING_PROJECTION_NOT_FOUND";
3724
5259
  readonly PLAN_ALREADY_EXISTS: "PLAN_ALREADY_EXISTS";
3725
5260
  readonly BUNDLE_ALREADY_EXISTS: "BUNDLE_ALREADY_EXISTS";
@@ -3730,6 +5265,10 @@ declare const CATALOG_ERROR_CODES: {
3730
5265
  readonly QUOTA_NOT_IN_DISCOVERY_SNAPSHOT: "QUOTA_NOT_IN_DISCOVERY_SNAPSHOT";
3731
5266
  readonly DISCOVERY_STATUS_TRANSITION_INVALID: "DISCOVERY_STATUS_TRANSITION_INVALID";
3732
5267
  readonly DISCOVERY_NOT_INITIALIZED: "DISCOVERY_NOT_INITIALIZED";
5268
+ /** The uploaded document is not a plan catalog — unparseable, or not an object. */
5269
+ readonly PLAN_CATALOG_UNREADABLE: "PLAN_CATALOG_UNREADABLE";
5270
+ /** It parsed, and then failed the schema or a cross-field rule. */
5271
+ readonly PLAN_CATALOG_INVALID: "PLAN_CATALOG_INVALID";
3733
5272
  };
3734
5273
  type CatalogErrorCode = (typeof CATALOG_ERROR_CODES)[keyof typeof CATALOG_ERROR_CODES];
3735
5274
  /** Bundle bookings on a tenant subscription. */
@@ -3743,6 +5282,8 @@ declare const BILLING_ERROR_CODES: {
3743
5282
  readonly BUNDLE_ALREADY_SUBSCRIBED: "BUNDLE_ALREADY_SUBSCRIBED";
3744
5283
  readonly BUNDLE_INCOMPATIBLE_WITH_PLAN: "BUNDLE_INCOMPATIBLE_WITH_PLAN";
3745
5284
  readonly BUNDLE_NOT_SELF_SERVICE: "BUNDLE_NOT_SELF_SERVICE";
5285
+ readonly BUNDLE_CYCLE_EXCEEDS_PLAN: "BUNDLE_CYCLE_EXCEEDS_PLAN";
5286
+ readonly BUNDLE_NOT_PRICED_FOR_THIS_PLAN: "BUNDLE_NOT_PRICED_FOR_THIS_PLAN";
3746
5287
  readonly SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED: "SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED";
3747
5288
  readonly SUBSCRIPTION_BUNDLE_NOT_CANCELLED: "SUBSCRIPTION_BUNDLE_NOT_CANCELLED";
3748
5289
  readonly SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE: "SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE";
@@ -3756,8 +5297,79 @@ declare const BILLING_ERROR_CODES: {
3756
5297
  readonly PLAN_NOT_IN_CATALOG: "PLAN_NOT_IN_CATALOG";
3757
5298
  /** Plan exists but cannot be booked via self-service. */
3758
5299
  readonly PLAN_NOT_SELF_SERVICE: "PLAN_NOT_SELF_SERVICE";
5300
+ /**
5301
+ * Plan carries no price for the requested billing cycle, so it is not sold
5302
+ * in it: a plan without a yearly price is a monthly plan.
5303
+ */
5304
+ readonly PLAN_NOT_SOLD_IN_CYCLE: "PLAN_NOT_SOLD_IN_CYCLE";
3759
5305
  /** Plan change refused. Carries `blockers[]` with their own codes. */
3760
5306
  readonly PLAN_CHANGE_BLOCKED: "PLAN_CHANGE_BLOCKED";
5307
+ /**
5308
+ * The subscription moved between the read a request was decided on and the
5309
+ * write it attempted, so nothing was written. The caller reloads and asks
5310
+ * again.
5311
+ */
5312
+ readonly SUBSCRIPTION_CHANGED: "SUBSCRIPTION_CHANGED";
5313
+ /**
5314
+ * The tenant has no subscription to act on.
5315
+ *
5316
+ * `SUBSCRIPTION_NOT_FOUND` states the same fact on the read routes. Both
5317
+ * are already on the wire and a code is renamed only deliberately, so both
5318
+ * are named here rather than one being dropped behind a consumer's back.
5319
+ */
5320
+ readonly NO_SUBSCRIPTION: "NO_SUBSCRIPTION";
5321
+ /**
5322
+ * The cancellation date the reader was shown is no longer the one the rules
5323
+ * return, so the confirmation is refused rather than silently applied.
5324
+ * Carries the recomputed dates, so the page can re-ask instead of guessing.
5325
+ */
5326
+ readonly CANCELLATION_TERMS_CHANGED: "CANCELLATION_TERMS_CHANGED";
5327
+ /** The subscription has ended; its plan can no longer be changed. */
5328
+ readonly SUBSCRIPTION_ENDED: "SUBSCRIPTION_ENDED";
5329
+ /** An active special contract blocks self-service plan changes. */
5330
+ readonly PLAN_LOCKED: "PLAN_LOCKED";
5331
+ /** Current usage of one quota exceeds what the target plan allows. */
5332
+ readonly QUOTA_OVER_TARGET: "QUOTA_OVER_TARGET";
5333
+ /** The change drops features the tenant has today. */
5334
+ readonly FEATURE_LOST: "FEATURE_LOST";
5335
+ readonly FEATURES_LOST: "FEATURES_LOST";
5336
+ /** Target plan and cycle already match what is in place. */
5337
+ readonly NO_CHANGE: "NO_CHANGE";
5338
+ /** A shorter cycle cannot start inside the term already running. */
5339
+ readonly CYCLE_SHORTENS_AT_TERM_END: "CYCLE_SHORTENS_AT_TERM_END";
5340
+ /** A cancelled subscription cannot change its billing cycle. */
5341
+ readonly CANCELLATION_LOCKS_THE_CYCLE: "CANCELLATION_LOCKS_THE_CYCLE";
5342
+ /**
5343
+ * A bundle the tenant already holds runs past the cycle they are moving to.
5344
+ *
5345
+ * Its own code rather than `BUNDLE_CYCLE_EXCEEDS_PLAN`, which states the
5346
+ * same rule about a booking that has not been made yet. The two need
5347
+ * different sentences: this one can name the day the obstacle lifts and
5348
+ * tell the reader to cancel the booking, and that advice is wrong for
5349
+ * someone who is only about to book. One template cannot serve both.
5350
+ */
5351
+ readonly BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE: "BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE";
5352
+ /**
5353
+ * Features of the previewed bundle are already covered by the plan or by
5354
+ * another booked bundle. A warning rather than a blocker: paying twice is
5355
+ * the customer's decision, and the preview only has to say so first.
5356
+ */
5357
+ readonly REDUNDANT_FEATURES: "REDUNDANT_FEATURES";
5358
+ /**
5359
+ * A booking's minimum term outlasts the period being cancelled, so the
5360
+ * cancellation takes effect at the end of the term, not of the period.
5361
+ */
5362
+ readonly MINIMUM_TERM_BINDS: "MINIMUM_TERM_BINDS";
5363
+ /**
5364
+ * The previewed bundle requires features that neither the plan nor an
5365
+ * active booking provides.
5366
+ *
5367
+ * The same string is a `StrictModeWarningCode` in `bundle.types.ts`, where
5368
+ * it names the catalogue-authoring reading of the rule and travels with its
5369
+ * own message. This declaration is the booking preview's blocker, which a
5370
+ * tenant reads and therefore needs a shipped text for.
5371
+ */
5372
+ readonly BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED";
3761
5373
  readonly NO_PENDING_PLAN_VERSION: "NO_PENDING_PLAN_VERSION";
3762
5374
  readonly ONBOARDING_CREATE_FAILED: "ONBOARDING_CREATE_FAILED";
3763
5375
  readonly BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS: "BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS";
@@ -3778,20 +5390,66 @@ declare const CONTRACT_ERROR_CODES: {
3778
5390
  readonly CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED";
3779
5391
  readonly CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE: "CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE";
3780
5392
  readonly CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED: "CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED";
5393
+ readonly CHECKOUT_OFFER_PLAN_NOT_OFFERED: "CHECKOUT_OFFER_PLAN_NOT_OFFERED";
5394
+ readonly CHECKOUT_OFFER_BUNDLE_NOT_OFFERED: "CHECKOUT_OFFER_BUNDLE_NOT_OFFERED";
5395
+ readonly CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED: "CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED";
5396
+ readonly CHECKOUT_OFFER_PRICE_NOT_CURRENT: "CHECKOUT_OFFER_PRICE_NOT_CURRENT";
3781
5397
  readonly SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED: "SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED";
3782
5398
  readonly SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED: "SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED";
3783
5399
  readonly SUBSCRIPTION_CONTRACT_INVALID_DATE: "SUBSCRIPTION_CONTRACT_INVALID_DATE";
3784
5400
  readonly SUBSCRIPTION_CONTRACT_INVALID_WINDOW: "SUBSCRIPTION_CONTRACT_INVALID_WINDOW";
5401
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH";
5402
+ readonly SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT: "SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT";
5403
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH";
5404
+ /**
5405
+ * The lines do not add up to a total the contract states, counted as often
5406
+ * as each falls due in one period. `field` names the total.
5407
+ */
5408
+ readonly SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP: "SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP";
5409
+ /** Something that takes money off states a negative amount. */
5410
+ readonly SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE: "SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE";
3785
5411
  readonly SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START";
3786
5412
  readonly CHECKOUT_OFFER_NOT_FOUND: "CHECKOUT_OFFER_NOT_FOUND";
3787
5413
  readonly CHECKOUT_OFFER_EXPIRED: "CHECKOUT_OFFER_EXPIRED";
3788
5414
  readonly CHECKOUT_OFFER_ALREADY_CONSUMED: "CHECKOUT_OFFER_ALREADY_CONSUMED";
3789
5415
  readonly CHECKOUT_OFFER_NOT_CONSUMED: "CHECKOUT_OFFER_NOT_CONSUMED";
5416
+ /**
5417
+ * The offer changed between the checks of `conclude` and the transaction
5418
+ * that consumed it, so the contract checked is not the one the offer now
5419
+ * describes. Nothing was written; load the offer and conclude it again.
5420
+ */
5421
+ readonly CHECKOUT_OFFER_CHANGED: "CHECKOUT_OFFER_CHANGED";
3790
5422
  readonly SUBSCRIPTION_CONTRACT_NOT_FOUND: "SUBSCRIPTION_CONTRACT_NOT_FOUND";
3791
5423
  readonly NO_ACTIVE_SUBSCRIPTION_CONTRACT: "NO_ACTIVE_SUBSCRIPTION_CONTRACT";
3792
5424
  readonly SUBSCRIPTION_CONTRACT_ALREADY_CLOSED: "SUBSCRIPTION_CONTRACT_ALREADY_CLOSED";
3793
5425
  };
3794
5426
  type ContractErrorCode = (typeof CONTRACT_ERROR_CODES)[keyof typeof CONTRACT_ERROR_CODES];
5427
+ /** The parties contracts are concluded with (`SubscriberService`), and their absence. */
5428
+ declare const SUBSCRIBER_ERROR_CODES: {
5429
+ /**
5430
+ * A contract, a plan change or a booking for a tenant that has no
5431
+ * subscriber. Nothing is agreed or charged without the party to it.
5432
+ * Carries `tenantId`.
5433
+ */
5434
+ readonly SUBSCRIBER_REQUIRED: "SUBSCRIBER_REQUIRED";
5435
+ /** The tenant already has a live subscriber. Carries `tenantId`. */
5436
+ readonly SUBSCRIBER_ALREADY_EXISTS: "SUBSCRIBER_ALREADY_EXISTS";
5437
+ readonly SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND";
5438
+ readonly SUBSCRIBER_LEGAL_NAME_REQUIRED: "SUBSCRIBER_LEGAL_NAME_REQUIRED";
5439
+ /**
5440
+ * A detail that has a form — the country, the invoice email — is not in it,
5441
+ * or one sign-up requires — the billing address — is missing. Carries `field`.
5442
+ */
5443
+ readonly SUBSCRIBER_DETAIL_INVALID: "SUBSCRIBER_DETAIL_INVALID";
5444
+ /** A contact change named a field of the legal identity. Carries `field`. */
5445
+ readonly SUBSCRIBER_IDENTITY_NOT_A_CONTACT: "SUBSCRIBER_IDENTITY_NOT_A_CONTACT";
5446
+ readonly SUBSCRIBER_CORRECTION_REASON_REQUIRED: "SUBSCRIBER_CORRECTION_REASON_REQUIRED";
5447
+ readonly SUBSCRIBER_CORRECTION_ACTOR_REQUIRED: "SUBSCRIBER_CORRECTION_ACTOR_REQUIRED";
5448
+ readonly SUBSCRIBER_CORRECTION_CHANGES_NOTHING: "SUBSCRIBER_CORRECTION_CHANGES_NOTHING";
5449
+ /** The operator declared another legal entity: that is a transfer, not an edit. */
5450
+ readonly SUBSCRIBER_TAKEOVER_IS_A_TRANSFER: "SUBSCRIBER_TAKEOVER_IS_A_TRANSFER";
5451
+ };
5452
+ type SubscriberErrorCode = (typeof SUBSCRIBER_ERROR_CODES)[keyof typeof SUBSCRIBER_ERROR_CODES];
3795
5453
  /** Self-service registration funnel (`PendingRegistration`). */
3796
5454
  declare const REGISTRATION_ERROR_CODES: {
3797
5455
  readonly PENDING_REGISTRATION_NOT_FOUND: "PENDING_REGISTRATION_NOT_FOUND";
@@ -3820,6 +5478,12 @@ declare const AUTH_ERROR_CODES: {
3820
5478
  /** Neither `tenantId` nor `userId` could be resolved from the request. */
3821
5479
  readonly TENANT_CONTEXT_MISSING: "TENANT_CONTEXT_MISSING";
3822
5480
  readonly TENANT_ADMIN_REQUIRED: "TENANT_ADMIN_REQUIRED";
5481
+ /**
5482
+ * The tenant's billing area — its payment method, and later its invoices
5483
+ * and account — needs the billing permission, which the application maps
5484
+ * to its roles and the tenant's administrator holds by default.
5485
+ */
5486
+ readonly BILLING_PERMISSION_REQUIRED: "BILLING_PERMISSION_REQUIRED";
3823
5487
  readonly SUPER_ADMIN_REQUIRED: "SUPER_ADMIN_REQUIRED";
3824
5488
  /** TOTP MFA has never been set up for this user. */
3825
5489
  readonly MFA_NOT_SET_UP: "MFA_NOT_SET_UP";
@@ -3850,6 +5514,30 @@ declare const PROMO_ERROR_CODES: {
3850
5514
  readonly PROMO_MAX_REDEMPTIONS_LOWERED: "PROMO_MAX_REDEMPTIONS_LOWERED";
3851
5515
  };
3852
5516
  type PromoErrorCode = (typeof PROMO_ERROR_CODES)[keyof typeof PROMO_ERROR_CODES];
5517
+ /** Payment methods and the gateways that confirm them. */
5518
+ declare const PAYMENT_ERROR_CODES: {
5519
+ /**
5520
+ * No gateway account takes new payment methods:
5521
+ * `config/saas.yaml#payments.newPaymentMethods` names none.
5522
+ */
5523
+ readonly PAYMENTS_NOT_CONFIGURED: "PAYMENTS_NOT_CONFIGURED";
5524
+ /** A callback arrived for an account `config/saas.yaml#payments.accounts` does not name. Carries `account`. */
5525
+ readonly PAYMENT_GATEWAY_ACCOUNT_UNKNOWN: "PAYMENT_GATEWAY_ACCOUNT_UNKNOWN";
5526
+ /** A callback the gateway did not send: its signature does not verify. */
5527
+ readonly PAYMENT_CALLBACK_REJECTED: "PAYMENT_CALLBACK_REJECTED";
5528
+ /**
5529
+ * A success or cancel URL at an origin `config/saas.yaml#payments.returnUrlOrigins`
5530
+ * does not name. Carries `field`.
5531
+ */
5532
+ readonly PAYMENT_RETURN_URL_NOT_ALLOWED: "PAYMENT_RETURN_URL_NOT_ALLOWED";
5533
+ };
5534
+ type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[keyof typeof PAYMENT_ERROR_CODES];
5535
+ /** Codes of the settings record (`GET /admin/settings`, the acknowledgement). */
5536
+ declare const SETTINGS_ERROR_CODES: {
5537
+ /** No recorded change has this id, or the installation keeps no record at all. */
5538
+ readonly SETTINGS_CHANGE_NOT_FOUND: "SETTINGS_CHANGE_NOT_FOUND";
5539
+ };
5540
+ type SettingsErrorCode = (typeof SETTINGS_ERROR_CODES)[keyof typeof SETTINGS_ERROR_CODES];
3853
5541
  /**
3854
5542
  * Every exception code the platform emits, in one object.
3855
5543
  *
@@ -3858,6 +5546,22 @@ type PromoErrorCode = (typeof PROMO_ERROR_CODES)[keyof typeof PROMO_ERROR_CODES]
3858
5546
  * removing one may not.
3859
5547
  */
3860
5548
  declare const PLATFORM_ERROR_CODES: {
5549
+ /** No recorded change has this id, or the installation keeps no record at all. */
5550
+ readonly SETTINGS_CHANGE_NOT_FOUND: "SETTINGS_CHANGE_NOT_FOUND";
5551
+ /**
5552
+ * No gateway account takes new payment methods:
5553
+ * `config/saas.yaml#payments.newPaymentMethods` names none.
5554
+ */
5555
+ readonly PAYMENTS_NOT_CONFIGURED: "PAYMENTS_NOT_CONFIGURED";
5556
+ /** A callback arrived for an account `config/saas.yaml#payments.accounts` does not name. Carries `account`. */
5557
+ readonly PAYMENT_GATEWAY_ACCOUNT_UNKNOWN: "PAYMENT_GATEWAY_ACCOUNT_UNKNOWN";
5558
+ /** A callback the gateway did not send: its signature does not verify. */
5559
+ readonly PAYMENT_CALLBACK_REJECTED: "PAYMENT_CALLBACK_REJECTED";
5560
+ /**
5561
+ * A success or cancel URL at an origin `config/saas.yaml#payments.returnUrlOrigins`
5562
+ * does not name. Carries `field`.
5563
+ */
5564
+ readonly PAYMENT_RETURN_URL_NOT_ALLOWED: "PAYMENT_RETURN_URL_NOT_ALLOWED";
3861
5565
  readonly PENDING_REGISTRATION_NOT_FOUND: "PENDING_REGISTRATION_NOT_FOUND";
3862
5566
  readonly PENDING_REGISTRATION_EXPIRED: "PENDING_REGISTRATION_EXPIRED";
3863
5567
  readonly INVALID_REGISTRATION_STATE: "INVALID_REGISTRATION_STATE";
@@ -3873,20 +5577,62 @@ declare const PLATFORM_ERROR_CODES: {
3873
5577
  readonly PLAN_NOT_AVAILABLE: "PLAN_NOT_AVAILABLE";
3874
5578
  readonly PLAN_NOT_SELECTED: "PLAN_NOT_SELECTED";
3875
5579
  readonly MODEL_NOT_AVAILABLE: "MODEL_NOT_AVAILABLE";
5580
+ /**
5581
+ * A contract, a plan change or a booking for a tenant that has no
5582
+ * subscriber. Nothing is agreed or charged without the party to it.
5583
+ * Carries `tenantId`.
5584
+ */
5585
+ readonly SUBSCRIBER_REQUIRED: "SUBSCRIBER_REQUIRED";
5586
+ /** The tenant already has a live subscriber. Carries `tenantId`. */
5587
+ readonly SUBSCRIBER_ALREADY_EXISTS: "SUBSCRIBER_ALREADY_EXISTS";
5588
+ readonly SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND";
5589
+ readonly SUBSCRIBER_LEGAL_NAME_REQUIRED: "SUBSCRIBER_LEGAL_NAME_REQUIRED";
5590
+ /**
5591
+ * A detail that has a form — the country, the invoice email — is not in it,
5592
+ * or one sign-up requires — the billing address — is missing. Carries `field`.
5593
+ */
5594
+ readonly SUBSCRIBER_DETAIL_INVALID: "SUBSCRIBER_DETAIL_INVALID";
5595
+ /** A contact change named a field of the legal identity. Carries `field`. */
5596
+ readonly SUBSCRIBER_IDENTITY_NOT_A_CONTACT: "SUBSCRIBER_IDENTITY_NOT_A_CONTACT";
5597
+ readonly SUBSCRIBER_CORRECTION_REASON_REQUIRED: "SUBSCRIBER_CORRECTION_REASON_REQUIRED";
5598
+ readonly SUBSCRIBER_CORRECTION_ACTOR_REQUIRED: "SUBSCRIBER_CORRECTION_ACTOR_REQUIRED";
5599
+ readonly SUBSCRIBER_CORRECTION_CHANGES_NOTHING: "SUBSCRIBER_CORRECTION_CHANGES_NOTHING";
5600
+ /** The operator declared another legal entity: that is a transfer, not an edit. */
5601
+ readonly SUBSCRIBER_TAKEOVER_IS_A_TRANSFER: "SUBSCRIBER_TAKEOVER_IS_A_TRANSFER";
3876
5602
  readonly CHECKOUT_OFFER_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_LINE_ITEMS_REQUIRED";
3877
5603
  readonly CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED: "CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED";
3878
5604
  readonly CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED";
3879
5605
  readonly CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE: "CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE";
3880
5606
  readonly CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED: "CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED";
5607
+ readonly CHECKOUT_OFFER_PLAN_NOT_OFFERED: "CHECKOUT_OFFER_PLAN_NOT_OFFERED";
5608
+ readonly CHECKOUT_OFFER_BUNDLE_NOT_OFFERED: "CHECKOUT_OFFER_BUNDLE_NOT_OFFERED";
5609
+ readonly CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED: "CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED";
5610
+ readonly CHECKOUT_OFFER_PRICE_NOT_CURRENT: "CHECKOUT_OFFER_PRICE_NOT_CURRENT";
3881
5611
  readonly SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED: "SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED";
3882
5612
  readonly SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED: "SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED";
3883
5613
  readonly SUBSCRIPTION_CONTRACT_INVALID_DATE: "SUBSCRIPTION_CONTRACT_INVALID_DATE";
3884
5614
  readonly SUBSCRIPTION_CONTRACT_INVALID_WINDOW: "SUBSCRIPTION_CONTRACT_INVALID_WINDOW";
5615
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH";
5616
+ readonly SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT: "SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT";
5617
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH";
5618
+ /**
5619
+ * The lines do not add up to a total the contract states, counted as often
5620
+ * as each falls due in one period. `field` names the total.
5621
+ */
5622
+ readonly SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP: "SUBSCRIPTION_CONTRACT_LINES_DO_NOT_ADD_UP";
5623
+ /** Something that takes money off states a negative amount. */
5624
+ readonly SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE: "SUBSCRIPTION_CONTRACT_DISCOUNT_NEGATIVE";
3885
5625
  readonly SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START";
3886
5626
  readonly CHECKOUT_OFFER_NOT_FOUND: "CHECKOUT_OFFER_NOT_FOUND";
3887
5627
  readonly CHECKOUT_OFFER_EXPIRED: "CHECKOUT_OFFER_EXPIRED";
3888
5628
  readonly CHECKOUT_OFFER_ALREADY_CONSUMED: "CHECKOUT_OFFER_ALREADY_CONSUMED";
3889
5629
  readonly CHECKOUT_OFFER_NOT_CONSUMED: "CHECKOUT_OFFER_NOT_CONSUMED";
5630
+ /**
5631
+ * The offer changed between the checks of `conclude` and the transaction
5632
+ * that consumed it, so the contract checked is not the one the offer now
5633
+ * describes. Nothing was written; load the offer and conclude it again.
5634
+ */
5635
+ readonly CHECKOUT_OFFER_CHANGED: "CHECKOUT_OFFER_CHANGED";
3890
5636
  readonly SUBSCRIPTION_CONTRACT_NOT_FOUND: "SUBSCRIPTION_CONTRACT_NOT_FOUND";
3891
5637
  readonly NO_ACTIVE_SUBSCRIPTION_CONTRACT: "NO_ACTIVE_SUBSCRIPTION_CONTRACT";
3892
5638
  readonly SUBSCRIPTION_CONTRACT_ALREADY_CLOSED: "SUBSCRIPTION_CONTRACT_ALREADY_CLOSED";
@@ -3899,6 +5645,8 @@ declare const PLATFORM_ERROR_CODES: {
3899
5645
  readonly BUNDLE_ALREADY_SUBSCRIBED: "BUNDLE_ALREADY_SUBSCRIBED";
3900
5646
  readonly BUNDLE_INCOMPATIBLE_WITH_PLAN: "BUNDLE_INCOMPATIBLE_WITH_PLAN";
3901
5647
  readonly BUNDLE_NOT_SELF_SERVICE: "BUNDLE_NOT_SELF_SERVICE";
5648
+ readonly BUNDLE_CYCLE_EXCEEDS_PLAN: "BUNDLE_CYCLE_EXCEEDS_PLAN";
5649
+ readonly BUNDLE_NOT_PRICED_FOR_THIS_PLAN: "BUNDLE_NOT_PRICED_FOR_THIS_PLAN";
3902
5650
  readonly SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED: "SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED";
3903
5651
  readonly SUBSCRIPTION_BUNDLE_NOT_CANCELLED: "SUBSCRIPTION_BUNDLE_NOT_CANCELLED";
3904
5652
  readonly SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE: "SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE";
@@ -3912,8 +5660,79 @@ declare const PLATFORM_ERROR_CODES: {
3912
5660
  readonly PLAN_NOT_IN_CATALOG: "PLAN_NOT_IN_CATALOG";
3913
5661
  /** Plan exists but cannot be booked via self-service. */
3914
5662
  readonly PLAN_NOT_SELF_SERVICE: "PLAN_NOT_SELF_SERVICE";
5663
+ /**
5664
+ * Plan carries no price for the requested billing cycle, so it is not sold
5665
+ * in it: a plan without a yearly price is a monthly plan.
5666
+ */
5667
+ readonly PLAN_NOT_SOLD_IN_CYCLE: "PLAN_NOT_SOLD_IN_CYCLE";
3915
5668
  /** Plan change refused. Carries `blockers[]` with their own codes. */
3916
5669
  readonly PLAN_CHANGE_BLOCKED: "PLAN_CHANGE_BLOCKED";
5670
+ /**
5671
+ * The subscription moved between the read a request was decided on and the
5672
+ * write it attempted, so nothing was written. The caller reloads and asks
5673
+ * again.
5674
+ */
5675
+ readonly SUBSCRIPTION_CHANGED: "SUBSCRIPTION_CHANGED";
5676
+ /**
5677
+ * The tenant has no subscription to act on.
5678
+ *
5679
+ * `SUBSCRIPTION_NOT_FOUND` states the same fact on the read routes. Both
5680
+ * are already on the wire and a code is renamed only deliberately, so both
5681
+ * are named here rather than one being dropped behind a consumer's back.
5682
+ */
5683
+ readonly NO_SUBSCRIPTION: "NO_SUBSCRIPTION";
5684
+ /**
5685
+ * The cancellation date the reader was shown is no longer the one the rules
5686
+ * return, so the confirmation is refused rather than silently applied.
5687
+ * Carries the recomputed dates, so the page can re-ask instead of guessing.
5688
+ */
5689
+ readonly CANCELLATION_TERMS_CHANGED: "CANCELLATION_TERMS_CHANGED";
5690
+ /** The subscription has ended; its plan can no longer be changed. */
5691
+ readonly SUBSCRIPTION_ENDED: "SUBSCRIPTION_ENDED";
5692
+ /** An active special contract blocks self-service plan changes. */
5693
+ readonly PLAN_LOCKED: "PLAN_LOCKED";
5694
+ /** Current usage of one quota exceeds what the target plan allows. */
5695
+ readonly QUOTA_OVER_TARGET: "QUOTA_OVER_TARGET";
5696
+ /** The change drops features the tenant has today. */
5697
+ readonly FEATURE_LOST: "FEATURE_LOST";
5698
+ readonly FEATURES_LOST: "FEATURES_LOST";
5699
+ /** Target plan and cycle already match what is in place. */
5700
+ readonly NO_CHANGE: "NO_CHANGE";
5701
+ /** A shorter cycle cannot start inside the term already running. */
5702
+ readonly CYCLE_SHORTENS_AT_TERM_END: "CYCLE_SHORTENS_AT_TERM_END";
5703
+ /** A cancelled subscription cannot change its billing cycle. */
5704
+ readonly CANCELLATION_LOCKS_THE_CYCLE: "CANCELLATION_LOCKS_THE_CYCLE";
5705
+ /**
5706
+ * A bundle the tenant already holds runs past the cycle they are moving to.
5707
+ *
5708
+ * Its own code rather than `BUNDLE_CYCLE_EXCEEDS_PLAN`, which states the
5709
+ * same rule about a booking that has not been made yet. The two need
5710
+ * different sentences: this one can name the day the obstacle lifts and
5711
+ * tell the reader to cancel the booking, and that advice is wrong for
5712
+ * someone who is only about to book. One template cannot serve both.
5713
+ */
5714
+ readonly BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE: "BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE";
5715
+ /**
5716
+ * Features of the previewed bundle are already covered by the plan or by
5717
+ * another booked bundle. A warning rather than a blocker: paying twice is
5718
+ * the customer's decision, and the preview only has to say so first.
5719
+ */
5720
+ readonly REDUNDANT_FEATURES: "REDUNDANT_FEATURES";
5721
+ /**
5722
+ * A booking's minimum term outlasts the period being cancelled, so the
5723
+ * cancellation takes effect at the end of the term, not of the period.
5724
+ */
5725
+ readonly MINIMUM_TERM_BINDS: "MINIMUM_TERM_BINDS";
5726
+ /**
5727
+ * The previewed bundle requires features that neither the plan nor an
5728
+ * active booking provides.
5729
+ *
5730
+ * The same string is a `StrictModeWarningCode` in `bundle.types.ts`, where
5731
+ * it names the catalogue-authoring reading of the rule and travels with its
5732
+ * own message. This declaration is the booking preview's blocker, which a
5733
+ * tenant reads and therefore needs a shipped text for.
5734
+ */
5735
+ readonly BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED";
3917
5736
  readonly NO_PENDING_PLAN_VERSION: "NO_PENDING_PLAN_VERSION";
3918
5737
  readonly ONBOARDING_CREATE_FAILED: "ONBOARDING_CREATE_FAILED";
3919
5738
  readonly BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS: "BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS";
@@ -3952,6 +5771,8 @@ declare const PLATFORM_ERROR_CODES: {
3952
5771
  readonly BUNDLE_VERSION_SUPERSEDED: "BUNDLE_VERSION_SUPERSEDED";
3953
5772
  readonly BUNDLE_VERSION_REGRESSION: "BUNDLE_VERSION_REGRESSION";
3954
5773
  readonly BUNDLE_VERSION_ZERO_PRICE: "BUNDLE_VERSION_ZERO_PRICE";
5774
+ readonly BUNDLE_VERSION_NO_PRICE: "BUNDLE_VERSION_NO_PRICE";
5775
+ readonly BUNDLE_VERSION_NOT_PRICED_FOR_PLAN: "BUNDLE_VERSION_NOT_PRICED_FOR_PLAN";
3955
5776
  readonly BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED: "BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED";
3956
5777
  readonly BUNDLE_VERSION_VALID_FROM_REQUIRED: "BUNDLE_VERSION_VALID_FROM_REQUIRED";
3957
5778
  readonly BUNDLE_VERSION_VALID_FROM_INVALID: "BUNDLE_VERSION_VALID_FROM_INVALID";
@@ -3969,6 +5790,12 @@ declare const PLATFORM_ERROR_CODES: {
3969
5790
  readonly FEATURE_NOT_FOUND: "FEATURE_NOT_FOUND";
3970
5791
  readonly QUOTA_NOT_FOUND: "QUOTA_NOT_FOUND";
3971
5792
  readonly PROMOTION_NOT_FOUND: "PROMOTION_NOT_FOUND";
5793
+ /**
5794
+ * A promotion's value is not one its type takes: a percentage above 0 and
5795
+ * at most 100, an amount above 0, an intro price of at least 0 for a whole
5796
+ * number of months, or a whole number of free months.
5797
+ */
5798
+ readonly PROMOTION_VALUE_INVALID: "PROMOTION_VALUE_INVALID";
3972
5799
  readonly MARKETING_PROJECTION_NOT_FOUND: "MARKETING_PROJECTION_NOT_FOUND";
3973
5800
  readonly PLAN_ALREADY_EXISTS: "PLAN_ALREADY_EXISTS";
3974
5801
  readonly BUNDLE_ALREADY_EXISTS: "BUNDLE_ALREADY_EXISTS";
@@ -3979,6 +5806,10 @@ declare const PLATFORM_ERROR_CODES: {
3979
5806
  readonly QUOTA_NOT_IN_DISCOVERY_SNAPSHOT: "QUOTA_NOT_IN_DISCOVERY_SNAPSHOT";
3980
5807
  readonly DISCOVERY_STATUS_TRANSITION_INVALID: "DISCOVERY_STATUS_TRANSITION_INVALID";
3981
5808
  readonly DISCOVERY_NOT_INITIALIZED: "DISCOVERY_NOT_INITIALIZED";
5809
+ /** The uploaded document is not a plan catalog — unparseable, or not an object. */
5810
+ readonly PLAN_CATALOG_UNREADABLE: "PLAN_CATALOG_UNREADABLE";
5811
+ /** It parsed, and then failed the schema or a cross-field rule. */
5812
+ readonly PLAN_CATALOG_INVALID: "PLAN_CATALOG_INVALID";
3982
5813
  readonly PROMO_CODE_NOT_FOUND: "PROMO_CODE_NOT_FOUND";
3983
5814
  readonly PROMO_CODE_ALREADY_EXISTS: "PROMO_CODE_ALREADY_EXISTS";
3984
5815
  readonly PROMO_CODE_HAS_REDEMPTIONS: "PROMO_CODE_HAS_REDEMPTIONS";
@@ -4001,6 +5832,12 @@ declare const PLATFORM_ERROR_CODES: {
4001
5832
  /** Neither `tenantId` nor `userId` could be resolved from the request. */
4002
5833
  readonly TENANT_CONTEXT_MISSING: "TENANT_CONTEXT_MISSING";
4003
5834
  readonly TENANT_ADMIN_REQUIRED: "TENANT_ADMIN_REQUIRED";
5835
+ /**
5836
+ * The tenant's billing area — its payment method, and later its invoices
5837
+ * and account — needs the billing permission, which the application maps
5838
+ * to its roles and the tenant's administrator holds by default.
5839
+ */
5840
+ readonly BILLING_PERMISSION_REQUIRED: "BILLING_PERMISSION_REQUIRED";
4004
5841
  readonly SUPER_ADMIN_REQUIRED: "SUPER_ADMIN_REQUIRED";
4005
5842
  /** TOTP MFA has never been set up for this user. */
4006
5843
  readonly MFA_NOT_SET_UP: "MFA_NOT_SET_UP";
@@ -4021,7 +5858,7 @@ declare const PLATFORM_ERROR_CODES: {
4021
5858
  /** Email already taken (mapped from `PlatformUserExistsError`). */
4022
5859
  readonly EMAIL_EXISTS: "EMAIL_EXISTS";
4023
5860
  };
4024
- type PlatformErrorCode = SetupErrorCode | AuthErrorCode | PromoErrorCode | CatalogErrorCode | BillingErrorCode | ContractErrorCode | RegistrationErrorCode;
5861
+ type PlatformErrorCode = SetupErrorCode | AuthErrorCode | PromoErrorCode | CatalogErrorCode | BillingErrorCode | ContractErrorCode | SubscriberErrorCode | RegistrationErrorCode | PaymentErrorCode | SettingsErrorCode;
4025
5862
  /**
4026
5863
  * Shape of a coded error response.
4027
5864
  *
@@ -4242,7 +6079,25 @@ interface PendingRegistration {
4242
6079
  billingCycle: 'MONTHLY' | 'YEARLY' | null;
4243
6080
  /** Plaintext code (UI display). Validation runs fresh every time. */
4244
6081
  appliedPromoCode: string | null;
6082
+ /**
6083
+ * The billing address and tax identifiers step 4 asks for, which the
6084
+ * subscriber is created with. The address is required before a payment
6085
+ * method is set up; the tax identifiers stay optional.
6086
+ */
6087
+ addressLine1: string | null;
6088
+ addressLine2: string | null;
6089
+ postalCode: string | null;
6090
+ city: string | null;
6091
+ /** ISO 3166-1 alpha-2, upper case. */
6092
+ country: string | null;
6093
+ vatId: string | null;
6094
+ taxNumber: string | null;
6095
+ /** The gateway's session for the payment method, unique within `checkoutGatewayAccount`. */
4245
6096
  checkoutSessionId: string | null;
6097
+ /** The account in `config/saas.yaml#payments.accounts` the session was opened at. */
6098
+ checkoutGatewayAccount: string | null;
6099
+ /** The customer the gateway created for the sign-up, reused when step 4 is repeated. */
6100
+ gatewayCustomerRef: string | null;
4246
6101
  checkoutStartedAt: Date | null;
4247
6102
  expiresAt: Date;
4248
6103
  createdAt: Date;
@@ -4274,7 +6129,16 @@ interface PendingRegistrationUpdateInput {
4274
6129
  configJson?: RegistrationConfigSelection | null;
4275
6130
  billingCycle?: 'MONTHLY' | 'YEARLY' | null;
4276
6131
  appliedPromoCode?: string | null;
6132
+ addressLine1?: string | null;
6133
+ addressLine2?: string | null;
6134
+ postalCode?: string | null;
6135
+ city?: string | null;
6136
+ country?: string | null;
6137
+ vatId?: string | null;
6138
+ taxNumber?: string | null;
4277
6139
  checkoutSessionId?: string | null;
6140
+ checkoutGatewayAccount?: string | null;
6141
+ gatewayCustomerRef?: string | null;
4278
6142
  checkoutStartedAt?: Date | null;
4279
6143
  expiresAt?: Date;
4280
6144
  }
@@ -4282,14 +6146,24 @@ interface PendingRegistrationUpdateInput {
4282
6146
  interface PendingRegistrationRepository {
4283
6147
  findById(id: string): Promise<PendingRegistration | null>;
4284
6148
  findByEmail(email: string): Promise<PendingRegistration | null>;
4285
- /** Webhook lookup: finds the pending record for the provider session. */
4286
- findByCheckoutSession(sessionId: string): Promise<PendingRegistration | null>;
6149
+ /**
6150
+ * Finds the pending record a gateway session belongs to. A session
6151
+ * identifier is unique only within its account, so both are matched.
6152
+ */
6153
+ findByCheckoutSession(gatewayAccount: string, sessionId: string): Promise<PendingRegistration | null>;
4287
6154
  /**
4288
6155
  * Cleanup lookup: all pending records with `expiresAt < now`, max
4289
6156
  * `limit` entries per call (batch protection). Ordering irrelevant, the
4290
6157
  * cron service iterates sequentially.
4291
6158
  */
4292
6159
  findExpired(now: Date, limit: number): Promise<PendingRegistration[]>;
6160
+ /**
6161
+ * Every gateway account a checkout session is still open at: the distinct
6162
+ * `checkoutGatewayAccount` of records in `CHECKOUT_STARTED` whose
6163
+ * `expiresAt` is after `now`. The start refuses when one of them is no
6164
+ * longer configured, because that sign-up's confirmation could not arrive.
6165
+ */
6166
+ findOpenCheckoutAccounts(now: Date): Promise<string[]>;
4293
6167
  create(input: PendingRegistrationCreateInput): Promise<PendingRegistration>;
4294
6168
  update(id: string, input: PendingRegistrationUpdateInput): Promise<PendingRegistration>;
4295
6169
  /**
@@ -4299,7 +6173,13 @@ interface PendingRegistrationRepository {
4299
6173
  * value is the authoritative threshold for the lockout check.
4300
6174
  */
4301
6175
  incrementOtpAttemptCount(id: string): Promise<number>;
4302
- delete(id: string): Promise<void>;
6176
+ /**
6177
+ * Removes the record. With `tx` it is removed on that transaction and comes
6178
+ * back with its rollback — an activation deletes the sign-up on the
6179
+ * transaction that creates the tenant, so a sign-up is either still waiting
6180
+ * or activated, never both.
6181
+ */
6182
+ delete(id: string, tx?: TransactionContext): Promise<void>;
4303
6183
  }
4304
6184
  /** Adapter port: detects whether a full user account (verified) exists for this email. */
4305
6185
  interface UserAccountLookup {
@@ -4309,67 +6189,42 @@ interface UserAccountLookup {
4309
6189
  interface SlugAvailabilityCheck {
4310
6190
  isSlugAvailable(slug: string): Promise<boolean>;
4311
6191
  }
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
6192
  interface FinalActivationResult {
4345
6193
  userId: string;
4346
6194
  tenantId: string;
4347
6195
  subscriptionId: string;
6196
+ /**
6197
+ * The subscriber created for the tenant in the same transaction — the party
6198
+ * its contracts are concluded with. `subscriberFromRegistration(pending)`
6199
+ * says what it is created with.
6200
+ */
6201
+ subscriberId: string;
6202
+ }
6203
+ /** The transaction a sign-up is activated on. */
6204
+ interface RegistrationActivation {
6205
+ /**
6206
+ * Opened by the platform, which has already claimed the gateway's
6207
+ * confirmation on it and records the confirmed payment method on it once
6208
+ * `activate` returns. Every row the activation writes goes through it, so
6209
+ * a failure anywhere rolls back all of it — the claim included, and the
6210
+ * gateway's retry is handled rather than discarded as a duplicate.
6211
+ */
6212
+ tx: TransactionContext;
4348
6213
  }
4349
6214
  /**
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).
6215
+ * Adapter port: creates User + Tenant + Subscriber + Subscription once the
6216
+ * gateway confirmed the sign-up's payment method. App-specific — each app has
6217
+ * its own schema (e.g. Tenant + TenantUser + Role + UserRole + Subscription).
4354
6218
  *
4355
- * Implementations MUST perform the creation in a DB transaction so that
4356
- * partial creations are fully rolled back on errors.
6219
+ * Implementations write on `activation.tx` and open no transaction of their
6220
+ * own: a write beside it would survive the rollback that undoes the rest. The
6221
+ * subscriber is created there too, before any contract:
6222
+ * `SubscriberService.createForTenant(tenantId, subscriberFromRegistration(pending),
6223
+ * activation.tx)`, or `CheckoutOfferService.conclude` with `subscriber` and
6224
+ * that transaction.
4357
6225
  */
4358
6226
  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;
6227
+ activate(pending: PendingRegistration, activation: RegistrationActivation): Promise<FinalActivationResult>;
4373
6228
  }
4374
6229
  interface CleanupResult {
4375
6230
  /** Number of deleted PendingRegistration records. */
@@ -4380,7 +6235,7 @@ interface CleanupResult {
4380
6235
  */
4381
6236
  moreAvailable: boolean;
4382
6237
  }
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';
6238
+ 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_FAILED' | 'ACTIVATION_COMPLETED' | 'LOGIN_SUCCEEDED' | 'LOGIN_INVALID_CREDENTIALS' | 'LOGIN_ONBOARDING_REQUIRED';
4384
6239
  /**
4385
6240
  * Context information that the audit layer records per event.
4386
6241
  * IP is expected as a hashed fingerprint — no plaintext IPs in the
@@ -4431,8 +6286,6 @@ interface ConfiguratorModel {
4431
6286
  popular?: boolean;
4432
6287
  }
4433
6288
  interface ConfiguratorCatalog {
4434
- /** Factor `yearlyNet = monthlyNet * cycleDiscount` (typically 10 = 2 months free). */
4435
- cycleDiscount: number;
4436
6289
  currency: string;
4437
6290
  vatRate: number;
4438
6291
  models: ConfiguratorModel[];
@@ -4457,6 +6310,10 @@ interface ConfiguratorPriceBreakdown {
4457
6310
  modelMonthlyNet: number;
4458
6311
  subtotalMonthlyNet: number;
4459
6312
  subtotalNet: number;
6313
+ /**
6314
+ * Net amount taken off `subtotalNet`: the promo preview's gross discount
6315
+ * converted at `vatRate`.
6316
+ */
4460
6317
  discountAmount: number;
4461
6318
  totalNet: number;
4462
6319
  vatRate: number;
@@ -4533,8 +6390,6 @@ interface ConfiguratorPlanMarketing {
4533
6390
  */
4534
6391
  interface ConfiguratorMarketingProvider {
4535
6392
  listPlanMarketing(): ConfiguratorPlanMarketing[];
4536
- /** Factor `yearlyNet = monthlyNet * cycleDiscount`. Default `10`. */
4537
- getCycleDiscount(): number;
4538
6393
  getVatRate(): number;
4539
6394
  getCurrency(): string;
4540
6395
  }
@@ -4555,6 +6410,7 @@ interface RegistrationPromoPreview {
4555
6410
  reason?: string;
4556
6411
  percent?: number;
4557
6412
  label?: string;
6413
+ /** The discount in gross, reckoned against `subtotalGross`. */
4558
6414
  discountAmount?: number;
4559
6415
  }>;
4560
6416
  }
@@ -4626,6 +6482,8 @@ interface PendingRegistrationSnapshot {
4626
6482
  config: RegistrationConfigSelection | null;
4627
6483
  billingCycle: 'MONTHLY' | 'YEARLY' | null;
4628
6484
  appliedPromoCode: string | null;
6485
+ /** The billing details step 4 already took, to fill its form again. */
6486
+ billingDetails: RegistrationBillingDetails | null;
4629
6487
  checkoutSessionId: string | null;
4630
6488
  }
4631
6489
  interface ResumeRegistrationResult {
@@ -4634,27 +6492,6 @@ interface ResumeRegistrationResult {
4634
6492
  nextStep: RegistrationStep;
4635
6493
  snapshot: PendingRegistrationSnapshot;
4636
6494
  }
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
6495
  /** Adapter port: OTP delivery via email (or another channel). */
4659
6496
  interface RegistrationOtpDelivery {
4660
6497
  sendVerificationOtp(params: {
@@ -4720,10 +6557,38 @@ interface SelectPlanResult {
4720
6557
  nextStep: RegistrationStep;
4721
6558
  selectedPlanId: string;
4722
6559
  }
6560
+ /**
6561
+ * The billing address and tax identifiers a sign-up gives in step 4, which its
6562
+ * subscriber is created with. The address is required; the tax identifiers
6563
+ * stay optional until the tax adapter says when one is needed.
6564
+ */
6565
+ interface RegistrationBillingDetails {
6566
+ addressLine1: string;
6567
+ addressLine2?: string | null;
6568
+ postalCode: string;
6569
+ city: string;
6570
+ /** ISO 3166-1 alpha-2, upper case. */
6571
+ country: string;
6572
+ vatId?: string | null;
6573
+ taxNumber?: string | null;
6574
+ }
4723
6575
  interface StartCheckoutInput {
4724
6576
  pendingRegistrationId: string;
6577
+ billingDetails: RegistrationBillingDetails;
6578
+ /** Where the gateway's form sends the person once the payment method is set up. */
4725
6579
  successUrl: string;
6580
+ /** Where the gateway's form sends the person who leaves it. */
4726
6581
  cancelUrl: string;
6582
+ /**
6583
+ * The checkout offer the sign-up concludes on activation. When it carries a
6584
+ * promo code, a slot of that code is held for it from this step until a
6585
+ * confirmation of the gateway's form can no longer arrive
6586
+ * (`PaymentMethodSetupSession.confirmableUntil`), so the code cannot run
6587
+ * out between this step and the payment confirmation; a code that cannot
6588
+ * be held refuses the step before the gateway's form opens. Left out,
6589
+ * nothing is held.
6590
+ */
6591
+ checkoutOfferId?: string | null;
4727
6592
  }
4728
6593
  interface StartCheckoutResult {
4729
6594
  pendingRegistrationId: string;
@@ -4766,6 +6631,344 @@ interface SetupConfirmMfaResponse {
4766
6631
  ok: boolean;
4767
6632
  }
4768
6633
 
6634
+ /** What the adapter's schema can actually answer about a version's dates. */
6635
+ interface PlanVersionMappingFields {
6636
+ /** `validFrom`/`validUntil` are maintained; otherwise both read as null. */
6637
+ validityWindows: boolean;
6638
+ /** `endsAt` exists; otherwise the field is left off the record entirely. */
6639
+ endsAt: boolean;
6640
+ }
6641
+ /** A `plans` row as either adapter reads it back. */
6642
+ interface CanonicalPlanRow {
6643
+ id: string;
6644
+ planKey: string;
6645
+ label: string;
6646
+ description: string | null;
6647
+ icon: string | null;
6648
+ sortOrder: number;
6649
+ createdAt: Date;
6650
+ updatedAt: Date;
6651
+ deletedAt: Date | null;
6652
+ }
6653
+ /** A `plan_versions` row as either adapter reads it back. */
6654
+ interface CanonicalPlanVersionRow {
6655
+ id: string;
6656
+ version: number;
6657
+ baseVersionId: string | null;
6658
+ features: unknown;
6659
+ quotas: unknown;
6660
+ monthlyNet: unknown;
6661
+ yearlyNet: unknown;
6662
+ marketed: boolean;
6663
+ publishedAt: Date | null;
6664
+ supersededAt: Date | null;
6665
+ publishedChanges: unknown;
6666
+ changeNote: string;
6667
+ nonRegressive: boolean;
6668
+ validFrom?: Date | null;
6669
+ validUntil?: Date | null;
6670
+ endsAt?: Date | null;
6671
+ createdByUserId: string | null;
6672
+ publishedByUserId: string | null;
6673
+ createdAt: Date;
6674
+ updatedAt: Date;
6675
+ }
6676
+ declare function toPlanRow(row: CanonicalPlanRow): PlanRow;
6677
+ /**
6678
+ * `planKey` is passed rather than read off the row: the canonical schema stores
6679
+ * the plan key in `planId`, but an adapter translating a consumer schema with a
6680
+ * real foreign key has to resolve it first, and only the adapter knows which
6681
+ * shape it is looking at.
6682
+ */
6683
+ declare function toPlanVersionRow(row: CanonicalPlanVersionRow, planKey: string, fields: PlanVersionMappingFields): PlanVersionRow;
6684
+
6685
+ /**
6686
+ * The legal identity of the issuer a catalogue names, or none where it names no
6687
+ * issuer.
6688
+ *
6689
+ * Settled through the same reader as the recorded side, and that symmetry is
6690
+ * the point rather than tidiness: the record is a verbatim copy of the file, so
6691
+ * a value whose two sides were settled differently would differ from itself. A
6692
+ * legal name written with a trailing space would then refuse the SECOND start on
6693
+ * a file nobody touched, and no declaration could make it stop happening.
6694
+ */
6695
+ declare function issuerIdentityOf(issuer: PlanCatalogIssuer | undefined): LegalIdentity | null;
6696
+ /**
6697
+ * The issuer identity a recorded settings tree holds.
6698
+ *
6699
+ * Read defensively rather than cast: the record is JSON as some earlier version
6700
+ * of this platform wrote it, and a tree without an issuer, or with one whose
6701
+ * legal name is not a name, is read as "no identity was recorded" — which is
6702
+ * the first naming, not a change. The schema keeps a name of only whitespace out
6703
+ * of the file; this keeps one out of a record written before it did.
6704
+ */
6705
+ declare function recordedIssuerIdentity(settings: AppliedSettingsValues | null | undefined): LegalIdentity | null;
6706
+ /** Why `issuer.correctionOf` does not cover the change it is there to declare. */
6707
+ type IssuerCorrectionFault =
6708
+ /** There is no declaration at all. */
6709
+ {
6710
+ kind: 'absent';
6711
+ }
6712
+ /**
6713
+ * The file names no issuer for a declaration to be about — the block is
6714
+ * gone, or it is there with a name that reads as nothing. Never a
6715
+ * correction, whatever is declared: there is no entity on this side for the
6716
+ * recorded one to be the same as.
6717
+ */
6718
+ | {
6719
+ kind: 'names-no-issuer';
6720
+ }
6721
+ /**
6722
+ * It names a value the record does not hold. Either the declaration is
6723
+ * stale — it belongs to a correction already applied — or it is about
6724
+ * another entity than the one this installation recorded.
6725
+ */
6726
+ | {
6727
+ kind: 'names-another-value';
6728
+ field: LegalIdentityField;
6729
+ declared: string | null;
6730
+ recorded: string | null;
6731
+ }
6732
+ /** It says nothing about a field the change moves, so that field is undeclared. */
6733
+ | {
6734
+ kind: 'leaves-a-field-out';
6735
+ field: LegalIdentityField;
6736
+ recorded: string | null;
6737
+ };
6738
+ /** What a start finds when it compares the file's issuer with the recorded one. */
6739
+ type IssuerIdentityChange =
6740
+ /** Neither the record nor the file names an issuer. */
6741
+ {
6742
+ kind: 'none-named';
6743
+ }
6744
+ /** The same entity, whatever the address and the contact details did. */
6745
+ | {
6746
+ kind: 'unchanged';
6747
+ identity: LegalIdentity;
6748
+ }
6749
+ /**
6750
+ * The first issuer this installation names. No contract can have been
6751
+ * concluded under another one, so nothing is declared for it.
6752
+ */
6753
+ | {
6754
+ kind: 'first-naming';
6755
+ identity: LegalIdentity;
6756
+ }
6757
+ /** The same entity, corrected as the file declares. `current` is never absent. */
6758
+ | {
6759
+ kind: 'corrected';
6760
+ recorded: LegalIdentity;
6761
+ current: LegalIdentity;
6762
+ moved: readonly LegalIdentityField[];
6763
+ reason: string;
6764
+ }
6765
+ /** Another identity, with no declaration that covers it. Refused. */
6766
+ | {
6767
+ kind: 'undeclared';
6768
+ recorded: LegalIdentity;
6769
+ /** `null` where the file names no issuer that has a name — see `names-no-issuer`. */
6770
+ current: LegalIdentity | null;
6771
+ moved: readonly LegalIdentityField[];
6772
+ fault: IssuerCorrectionFault;
6773
+ };
6774
+ /**
6775
+ * What the issuer in the file is, against the identity the record holds.
6776
+ *
6777
+ * `recorded` is `null` on the very first start, and on an installation that
6778
+ * has never named an issuer.
6779
+ */
6780
+ declare function classifyIssuerChange(recorded: LegalIdentity | null, issuer: PlanCatalogIssuer | undefined): IssuerIdentityChange;
6781
+
6782
+ /** A `subscribers` row as either adapter reads it back. */
6783
+ interface CanonicalSubscriberRow {
6784
+ id: string;
6785
+ customerSequence: number;
6786
+ customerNumberPrefix: string;
6787
+ legalName: string;
6788
+ addressLine1: string | null;
6789
+ addressLine2: string | null;
6790
+ postalCode: string | null;
6791
+ city: string | null;
6792
+ country: string | null;
6793
+ vatId: string | null;
6794
+ taxNumber: string | null;
6795
+ invoiceEmail: string | null;
6796
+ migrated: boolean;
6797
+ createdAt: Date;
6798
+ updatedAt: Date;
6799
+ }
6800
+ /** A `subscriber_corrections` row as either adapter reads it back. */
6801
+ interface CanonicalSubscriberCorrectionRow {
6802
+ id: string;
6803
+ subscriberId: string;
6804
+ previous: unknown;
6805
+ corrected: unknown;
6806
+ reason: string;
6807
+ correctedBy: string;
6808
+ correctedAt: Date;
6809
+ }
6810
+ /**
6811
+ * The customer number a subscriber is known by: the prefix it was assigned
6812
+ * with, then the number. Kept apart in storage so the number orders and the
6813
+ * prefix stays what it was on the day it was assigned.
6814
+ */
6815
+ declare function formatCustomerNumber(prefix: string, sequence: number): string;
6816
+ /** `tenantId` is the tenant the subscriber's live link names, read beside the row. */
6817
+ declare function toSubscriberRecord(row: CanonicalSubscriberRow, tenantId: string | null): SubscriberRecord;
6818
+ declare function toSubscriberCorrectionRecord(row: CanonicalSubscriberCorrectionRow): SubscriberCorrectionRecord;
6819
+ /**
6820
+ * The part of a correction that changes something: every corrected field whose
6821
+ * stored value differs, with the value it replaces. Both adapters decide this
6822
+ * the same way, under the lock they read `current` with.
6823
+ */
6824
+ declare function identityCorrectionDelta(current: Pick<SubscriberRecord, LegalIdentityField>, corrected: SubscriberIdentityValues): SubscriberIdentityDelta;
6825
+ /**
6826
+ * Who a new contract is between: the subscriber as it stands, and the issuer as
6827
+ * the running configuration names it — or no issuer, where it names none.
6828
+ */
6829
+ declare function contractPartiesOf(subscriber: SubscriberRecord, issuer: PlanCatalog['issuer']): SubscriptionContractParties;
6830
+ /**
6831
+ * The subscriber a completed sign-up is created with: the name the tenant was
6832
+ * registered under as its legal name, the address the registration was
6833
+ * verified with as its invoice email, and the billing address and tax
6834
+ * identifiers step 4 took.
6835
+ */
6836
+ declare function subscriberFromRegistration(pending: Pick<PendingRegistration, 'tenantName' | 'email' | 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'country' | 'vatId' | 'taxNumber'>): NewSubscriberDetails;
6837
+
6838
+ /** The payment method types, in the order a form offers them. */
6839
+ declare const PAYMENT_METHOD_TYPES: readonly PaymentMethodType[];
6840
+ /** A `subscriber_payment_methods` row as either adapter reads it back. */
6841
+ interface CanonicalSubscriberPaymentMethodRow {
6842
+ id: string;
6843
+ subscriberId: string;
6844
+ gatewayAccount: string;
6845
+ provider: string;
6846
+ customerRef: string;
6847
+ paymentMethodRef: string;
6848
+ type: string;
6849
+ brand: string | null;
6850
+ last4: string;
6851
+ expiryMonth: number | null;
6852
+ expiryYear: number | null;
6853
+ country: string | null;
6854
+ bankCode: string | null;
6855
+ mandateReference: string | null;
6856
+ status: string;
6857
+ confirmedAt: Date;
6858
+ replacedAt: Date | null;
6859
+ createdAt: Date;
6860
+ }
6861
+ /**
6862
+ * Reads a row back as a record.
6863
+ *
6864
+ * The two text columns with a closed set of values are checked rather than
6865
+ * cast: a value written by hand or by an older release would otherwise reach a
6866
+ * screen as a type nobody handles.
6867
+ */
6868
+ declare function toSubscriberPaymentMethodRecord(row: CanonicalSubscriberPaymentMethodRow): SubscriberPaymentMethodRecord;
6869
+ /** The columns a confirmed payment method is written with, and nothing a caller added beside them. */
6870
+ declare function subscriberPaymentMethodColumns(data: RecordSubscriberPaymentMethodData): Omit<CanonicalSubscriberPaymentMethodRow, 'id' | 'status' | 'replacedAt' | 'createdAt'>;
6871
+ /**
6872
+ * A confirmation naming a reference that belongs to another subscriber.
6873
+ *
6874
+ * `(gatewayAccount, paymentMethodRef)` is unique account-wide rather than per
6875
+ * subscriber, and that is what makes the reference one subscriber's for good:
6876
+ * the second row cannot be written. This is the same boundary drawn for the
6877
+ * caller, so that a confirmation is refused rather than answered as the
6878
+ * duplicate of a payment method that is not this subscriber's — on a gateway
6879
+ * callback, which arrives without a session and with the tenant policy lifted,
6880
+ * so nothing else there bounds the question.
6881
+ *
6882
+ * That a provider issues a reference once per payer is a property of that
6883
+ * provider and no promise of this platform's, which is why the condition is
6884
+ * checked rather than assumed.
6885
+ *
6886
+ * It names neither the subscriber that holds the reference nor the tenant
6887
+ * behind it: whoever reads the log of the refused confirmation is on the other
6888
+ * side of the boundary this refusal draws. The account and the reference are
6889
+ * carried as fields so that a caller can say which confirmation was refused
6890
+ * without taking the sentence apart.
6891
+ */
6892
+ declare class ForeignPaymentMethodReferenceError extends Error {
6893
+ readonly gatewayAccount: string;
6894
+ readonly paymentMethodRef: string;
6895
+ readonly code = "FOREIGN_PAYMENT_METHOD_REFERENCE";
6896
+ constructor(gatewayAccount: string, paymentMethodRef: string);
6897
+ }
6898
+ /** Realm-safe type guard, like `isPaymentCallbackRejectedError`. */
6899
+ declare function isForeignPaymentMethodReferenceError(error: unknown): error is ForeignPaymentMethodReferenceError;
6900
+ /**
6901
+ * Refuses a reference of `gatewayAccount` that another subscriber already
6902
+ * holds, where the row could be read.
6903
+ *
6904
+ * A write cannot rely on this alone: a read does not see a row a concurrent
6905
+ * transaction has not committed. What the reference's unique key refuses is
6906
+ * refused with the same error — see `SubscriberPaymentMethodRepository`.
6907
+ */
6908
+ declare function refuseForeignPaymentMethodReference(recorded: Pick<CanonicalSubscriberPaymentMethodRow, 'subscriberId' | 'gatewayAccount' | 'paymentMethodRef'>, subscriberId: string): void;
6909
+
6910
+ /** A `subscription_contracts` row as either adapter reads it back. */
6911
+ interface CanonicalContractRow {
6912
+ id: string;
6913
+ tenantId: string;
6914
+ subscriberId: string;
6915
+ subscriberSnapshot: unknown;
6916
+ issuerSnapshot: unknown;
6917
+ partiesMigrated: boolean;
6918
+ status: string;
6919
+ effectiveFrom: Date;
6920
+ effectiveUntil: Date | null;
6921
+ originalOfferId: string | null;
6922
+ originalPlanVersionId: string | null;
6923
+ originalBundleVersionIds: unknown;
6924
+ entitlementSnapshot: unknown;
6925
+ priceSnapshot: unknown;
6926
+ promotionSnapshots: unknown;
6927
+ promoCodeSnapshots: unknown;
6928
+ termsSnapshot: unknown;
6929
+ createdAt: Date;
6930
+ updatedAt: Date;
6931
+ }
6932
+ /** A `contract_line_items` row as either adapter reads it back. */
6933
+ interface CanonicalContractLineItemRow {
6934
+ id: string;
6935
+ contractId: string;
6936
+ kind: string;
6937
+ sourceKey: string;
6938
+ sourceVersionId: string | null;
6939
+ titleSnapshot: string;
6940
+ descriptionSnapshot: string | null;
6941
+ quantity: number;
6942
+ unit: string | null;
6943
+ priceNet: unknown;
6944
+ priceGross: unknown;
6945
+ billingCycle: string;
6946
+ currency: string;
6947
+ taxRate: unknown;
6948
+ taxAmount: unknown;
6949
+ minimumTermUntil: Date | null;
6950
+ featuresSnapshot: unknown;
6951
+ quotaEffectsSnapshot: unknown;
6952
+ metadata: unknown;
6953
+ createdAt: Date;
6954
+ }
6955
+ declare function toSubscriptionContractRecord(row: CanonicalContractRow, lineItems: CanonicalContractLineItemRow[]): SubscriptionContractRecord;
6956
+ declare function toContractLineItemRecord(row: CanonicalContractLineItemRow): ContractLineItemRecord;
6957
+ /**
6958
+ * The columns the issuer check reads off a running contract. A narrow row of
6959
+ * its own, because that query selects four columns rather than the whole
6960
+ * contract with its lines: it runs at every start, and an installation with
6961
+ * thousands of running contracts should not load them to count them.
6962
+ */
6963
+ interface CanonicalRunningContractRow {
6964
+ id: string;
6965
+ tenantId: string;
6966
+ issuerSnapshot: unknown;
6967
+ effectiveFrom: Date;
6968
+ }
6969
+ /** One running contract, and the legal name on its issuer copy where it has one. */
6970
+ declare function toRunningContractIssuer(row: CanonicalRunningContractRow): RunningContractIssuer;
6971
+
4769
6972
  /** Why a version is editable (for UI badges + audit logs). */
4770
6973
  type VersionEditableReason = 'draft' | 'pre-active';
4771
6974
  interface VersionEditability {
@@ -4780,6 +6983,30 @@ interface VersionEditability {
4780
6983
  */
4781
6984
  declare function isVersionEditable(v: VersionedEntityBase, now?: Date): VersionEditability;
4782
6985
 
6986
+ /** The part of a plan card this rule reads and writes. */
6987
+ interface RecommendablePlan {
6988
+ planKey: string;
6989
+ highlight: boolean;
6990
+ }
6991
+ /**
6992
+ * Leaves the mark on at most one plan, in place, and returns the winner.
6993
+ *
6994
+ * `inRequestedLocale` holds the keys of the plans whose card was described in
6995
+ * the language that was asked for. Where a caller has no fallback to model —
6996
+ * the SuperAdmin edits one language at a time — passing every key, or none,
6997
+ * gives the same answer: the first plan in the list order wins.
6998
+ *
6999
+ * The list order is the caller's, and it is what the reader sees, so the
7000
+ * answer is the first recommended card on the page. Said exactly, because it
7001
+ * is easy to overstate: the tie-break inherits whatever order the caller
7002
+ * arranged, and where two plans are equal by every criterion it sorted on,
7003
+ * that order is the repository's. `PlanRepository.list` promises none, so an
7004
+ * adapter that returns rows in a different order each time would move the mark
7005
+ * between two otherwise indistinguishable plans. The shipped adapters order
7006
+ * totally.
7007
+ */
7008
+ declare function keepOneRecommended<T extends RecommendablePlan>(plans: T[], inRequestedLocale: ReadonlySet<string>): T | null;
7009
+
4783
7010
  declare const ERROR_MESSAGES_EN: Record<PlatformErrorCode, string>;
4784
7011
  /** Values available for interpolation into a message template. */
4785
7012
  type ErrorMessageParams = Record<string, unknown>;
@@ -4789,6 +7016,23 @@ type ErrorMessageParams = Record<string, unknown>;
4789
7016
  * vanishing.
4790
7017
  */
4791
7018
  declare function formatErrorMessage(template: string, params?: ErrorMessageParams): string;
7019
+ /**
7020
+ * An error body this function can read.
7021
+ *
7022
+ * `code` is widened past `PlatformErrorCode` on purpose. The function checks
7023
+ * `typeof body.code === 'string'` and resolves whatever it finds, and the
7024
+ * `overrides` parameter exists so a consumer can bring its own codes — one
7025
+ * consumer carries 98 of them against the platform's 135, overlapping in five.
7026
+ * A closed union here would reject exactly the case the parameter is for, and
7027
+ * the cast that works around it is one a reader has to be told is deliberate.
7028
+ *
7029
+ * `PlatformErrorBody` stays closed: a body the *platform* produces really does
7030
+ * carry a platform code. It is the reader that has to accept more. The same
7031
+ * shape appears in `SaLocale` next door, for the same reason.
7032
+ */
7033
+ type ResolvableErrorBody = Omit<Partial<PlatformErrorBody>, 'code'> & Record<string, unknown> & {
7034
+ code?: PlatformErrorCode | (string & {});
7035
+ };
4792
7036
  /**
4793
7037
  * Turns an error body into display text.
4794
7038
  *
@@ -4802,8 +7046,8 @@ declare function formatErrorMessage(template: string, params?: ErrorMessageParam
4802
7046
  * second, so a template may name either without the value being duplicated on
4803
7047
  * the wire.
4804
7048
  */
4805
- declare function resolveErrorMessage(body: Partial<PlatformErrorBody> & Record<string, unknown>, overrides?: Partial<Record<string, string>>, defaults?: Partial<Record<string, string>>): string;
7049
+ declare function resolveErrorMessage(body: ResolvableErrorBody, overrides?: Partial<Record<string, string>>, defaults?: Partial<Record<string, string>>): string;
4806
7050
 
4807
7051
  declare const ERROR_MESSAGES_DE: Record<PlatformErrorCode, string>;
4808
7052
 
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 };
7053
+ export { ACTIVE_SUBSCRIPTION_CONTRACT_STATUSES, 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 AppliedSettingsPort, type AppliedSettingsRecord, type AppliedSettingsValues, 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, BUNDLE_PRICE_LOOKUP_LIMIT, type BillingCycle, type BillingErrorCode, type BundleAvailabilityState, type BundleCompatibility, type BundleFeatureShape, type BundleListFilter, type BundlePricingOverride, type BundleRepository, type BundleRow, type BundleVersionFields, type BundleVersionMutationResult, type BundleVersionRow, CATALOGUE_KEYS, CATALOG_ERROR_CODES, CONTRACT_ERROR_CODES, type CancelSubscriptionBundleData, type CancelSubscriptionInput, type CancelSubscriptionResult, type CancellationNoticePeriods, type CanonicalContractLineItemRow, type CanonicalContractRow, type CanonicalPlanRow, type CanonicalPlanVersionRow, type CanonicalRunningContractRow, type CanonicalSubscriberCorrectionRow, type CanonicalSubscriberPaymentMethodRow, type CanonicalSubscriberRow, 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 CheckoutOfferSelection, type CheckoutOfferSelectionUpdate, type CheckoutOfferStatus, type CleanupResult, type CliUserRow, type ComponentKey, type ConfiguratorCatalog, type ConfiguratorMarketingProvider, type ConfiguratorModel, type ConfiguratorPlanMarketing, type ConfiguratorPlanVersionRow, type ConfiguratorPriceBreakdown, type ConfiguratorSourcesLookup, type ConfirmedPaymentMethod, type ContractErrorCode, type ContractIssuerParty, type ContractLineItemKind, type ContractLineItemRecord, type ContractSubscriberParty, type CreateBundleData, type CreateBundleVersionDraftData, type CreateCheckoutOfferData, type CreateMarketingProjectionData, type CreatePlanData, type CreatePlanVersionDraftData, type CreatePromoCodeData, type CreatePromoCodeRequest, type CreatePromotionData, type CreateSubscriberData, 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 EmailPort, 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, ForeignPaymentMethodReferenceError, type ImmediatePlanChangeInput, type InvoiceLineItemSnapshot, type IssuerCorrectionFault, type IssuerIdentityChange, type KpiCardDef, type KpiDisplayHint, LEGAL_IDENTITY_FIELDS, type LegalIdentity, type LegalIdentityField, MARKETING_PRIORITY_MAX, MARKETING_PRIORITY_MIN, type ManifestAccessPort, type ManifestContribution, type MarketingProjectionFilter, type MarketingProjectionRepository, type MarketingProjectionRow, type MarketingSettingsRepository, type MarketingSettingsRow, type MarketingTargetType, type MarketingTopFeature, type MaskedPaymentMethod, type MfaPort, type NewContractLineItemData, type NewSettingsChange, type NewSubscriberDetails, type NewSubscriptionContractData, 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, PAYMENT_ERROR_CODES, PAYMENT_METHOD_TYPES, PENDING_CHECKOUT_TTL_DAYS, PENDING_EMAIL_TTL_HOURS, PENDING_ONBOARDING_TTL_DAYS, PLATFORM_ERROR_CODES, PROMO_ERROR_CODES, type Paginated, type PartyAddress, type PasswordHasher, type PasswordResetCliResult, PaymentCallbackRejectedError, type PaymentErrorCode, type PaymentEventClaim, type PaymentEventLog, type PaymentGateway, type PaymentGatewayCallback, type PaymentGatewayEvent, type PaymentMethodHolder, type PaymentMethodSetupSession, type PaymentMethodSetupSubject, type PaymentMethodType, 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 PlanCatalogIssuer, type PlanCatalogIssuerCorrection, type PlanCatalogLookup, type PlanCatalogMarketing, type PlanCatalogNotifications, type PlanCatalogPaymentAccount, type PlanCatalogPayments, type PlanCatalogReadSink, type PlanCatalogReadSnapshot, type PlanCatalogSettings, type PlanCatalogSubscribers, type PlanCatalogTenantBilling, type PlanDef, type PlanId, type PlanListFilter, type PlanRepository, type PlanRow, type PlanVersion, type PlanVersionFields, type PlanVersionMappingFields, 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 PromoCodeHoldRecord, type PromoCodeHoldRepository, type PromoCodeHoldTaken, 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 PromotionI18n, type PromotionI18nFields, type PromotionOnPrice, 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 PublicNewPaymentMethods, type PublicSignupPlan, type PublishBundleVersionData, type PublishBundleVersionMeta, type PublishPlanVersionData, type QuotaCatalogEntryRow, type QuotaEnforcementMode, type QuotaKey, type QuotaProvider, REGISTRATION_ERROR_CODES, REGISTRATION_RESUME_TTL_MINUTES, REGISTRATION_STEP_BY_STATUS, type ReassignTenantAdminCliResult, type RecommendablePlan, type RecordSubscriberPaymentMethodData, type RecordSubscriberPaymentMethodOutcome, type RecordSubscriberPaymentMethodResult, type RedeemPromoInTransactionCallback, type RegistrationActivation, type RegistrationAuditContext, type RegistrationAuditEvent, type RegistrationAuditEventType, type RegistrationAuditLogger, type RegistrationBillingDetails, type RegistrationConfigSelection, type RegistrationConfiguratorLookup, type RegistrationErrorCode, type RegistrationOtpDelivery, type RegistrationPromoPreview, type RegistrationResumeDelivery, type RegistrationResumeTokenSigner, type RegistrationStatus, type RegistrationStep, type RequiredCapabilities, type ResolvableErrorBody, type ResumeRegistrationInput, type ResumeRegistrationResult, type ReviewCatalogEntryData, type RlsBypassPort, type RunningContractIssuer, type RunningContractIssuers, SETTINGS_ERROR_CODES, SETUP_ERROR_CODES, SUBSCRIBER_ERROR_CODES, type SaaSiCatPersistenceAdapter, type SaaSiCatPersistenceAdminResources, type SaaSiCatPersistenceCatalog, type SaaSiCatPersistenceCore, type SaaSiCatPersistenceEntitlement, type SaaSiCatPersistencePayments, type SaaSiCatPersistencePromo, type SaaSiCatPersistenceTenantBilling, type SaveRegistrationConfigInput, type SaveRegistrationConfigResult, type ScheduledPlanChangeInput, type SelectPlanInput, type SelectPlanResult, type SelectableBundleShape, type SelfServiceBlockedPlans, type SetCatalogEntryReviewData, type SettingsChangeFilter, type SettingsChangeRecord, type SettingsDifference, type SettingsErrorCode, type SetupConfirmMfaRequest, type SetupConfirmMfaResponse, type SetupErrorCode, type SetupRequest, type SetupResult, type SetupStatusResponse, type SlugAvailabilityCheck, type StandardPageDef, type StandardPageKey, type StartCheckoutInput, type StartCheckoutResult, type StartPaymentMethodSetupInput, type StartRegistrationInput, type StartRegistrationResult, type StoredBundleStem, type StrictModeWarning, type StrictModeWarningCode, type SubscriberContact, type SubscriberContactChange, type SubscriberCorrectionData, type SubscriberCorrectionRecord, type SubscriberCorrectionResult, type SubscriberDetails, type SubscriberErrorCode, type SubscriberIdentityCorrection, type SubscriberIdentityDelta, type SubscriberIdentityValues, type SubscriberPaymentMethodRecord, type SubscriberPaymentMethodReference, type SubscriberPaymentMethodRepository, type SubscriberPaymentMethodSetupData, type SubscriberPaymentMethodSetupMatch, type SubscriberPaymentMethodStatus, type SubscriberRecord, type SubscriberRepository, type Subscription, type SubscriptionBundleRecord, type SubscriptionBundleRepository, type SubscriptionBundleView, type SubscriptionContractFilter, type SubscriptionContractInvoiceSnapshot, type SubscriptionContractParties, 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, bundleDraftDefaults, bundleStemDefaults, canonicalJson, classifyBundleVersionDiff, classifyIssuerChange, classifyPlanDiff, collectUnsatisfiedRequires, contractPartiesOf, coverageExcludingSelf, definedFields, diffSettings, formatCustomerNumber, formatErrorMessage, identityCorrectionDelta, isBundleRedundant, isForeignPaymentMethodReferenceError, isPaymentCallbackRejectedError, isPlatformUserExistsError, isVersionEditable, issuerIdentityOf, keepOneRecommended, missingRequiresFor, movedIdentityFields, pickActivePromo, planCatalogSettingsOf, previousUtcDay, promoStatus, promotionOnPrice, readQuotaRecord, readQuotaValue, recordedIssuerIdentity, refuseForeignPaymentMethodReference, resolveBundleAvailability, resolveErrorMessage, sameLegalIdentity, selectChargeableBundles, settingsSubtreeOf, startOfUtcDay, subscriberFromRegistration, subscriberPaymentMethodColumns, toBundleStemRow, toContractLineItemRecord, toPlanRow, toPlanVersionRow, toRunningContractIssuer, toSubscriberCorrectionRecord, toSubscriberPaymentMethodRecord, toSubscriberRecord, toSubscriptionContractRecord };