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

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.ts 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,42 +437,434 @@ interface MarketingProjectionRow {
668
437
  createdAt: string;
669
438
  updatedAt: string;
670
439
  }
671
- /** Filter for `MarketingProjectionRepository.list()`. At least projectKey. */
672
- interface MarketingProjectionFilter {
673
- projectKey: string;
674
- targetType?: MarketingTargetType;
675
- targetVersionId?: string;
676
- locale?: string;
440
+ /** Filter for `MarketingProjectionRepository.list()`. */
441
+ interface MarketingProjectionFilter {
442
+ targetType?: MarketingTargetType;
443
+ targetVersionId?: string;
444
+ locale?: string;
445
+ }
446
+ interface CreateMarketingProjectionData {
447
+ targetType: MarketingTargetType;
448
+ targetVersionId: string;
449
+ locale?: string;
450
+ displayLabel: string;
451
+ description: string;
452
+ visible?: boolean;
453
+ badge?: string;
454
+ topFeatures?: MarketingTopFeature[];
455
+ trialEnabled?: boolean;
456
+ trialDays?: number;
457
+ priceTag?: string | null;
458
+ ctaLabel?: string | null;
459
+ priority?: number;
460
+ highlight?: boolean;
461
+ }
462
+ interface UpdateMarketingProjectionData {
463
+ displayLabel?: string;
464
+ description?: string;
465
+ visible?: boolean;
466
+ badge?: string;
467
+ topFeatures?: MarketingTopFeature[];
468
+ trialEnabled?: boolean;
469
+ trialDays?: number;
470
+ priceTag?: string | null;
471
+ ctaLabel?: string | null;
472
+ priority?: number;
473
+ highlight?: boolean;
474
+ }
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
+ * A confirmation the gateway already holds when the session starts, to be
569
+ * handled like any callback. Only a gateway without a form of its own has
570
+ * one — the development gateway; a real gateway confirms through its
571
+ * webhook.
572
+ */
573
+ immediateCallback?: PaymentGatewayCallback;
574
+ }
575
+ /**
576
+ * What SaaSiCat keeps about a payment method: enough to show which one it is,
577
+ * never enough to pay with it (`SC-PRIV-005`).
578
+ */
579
+ interface MaskedPaymentMethod {
580
+ type: PaymentMethodType;
581
+ /** The card network, such as `visa`; `null` for a direct debit. */
582
+ brand: string | null;
583
+ /** The last four digits of the card number or the IBAN. */
584
+ last4: string;
585
+ /** 1–12; `null` for a direct debit. */
586
+ expiryMonth: number | null;
587
+ /** Four digits; `null` for a direct debit. */
588
+ expiryYear: number | null;
589
+ /** ISO 3166-1 alpha-2 of the card's issuer or the bank account, where the gateway says. */
590
+ country: string | null;
591
+ /** The bank code of a direct debit account, where the gateway says. */
592
+ bankCode: string | null;
593
+ /** The reference of a direct debit mandate, which a debit announcement quotes. */
594
+ mandateReference: string | null;
595
+ }
596
+ /** A payment method the gateway confirmed, with the references that reach it there. */
597
+ interface ConfirmedPaymentMethod extends MaskedPaymentMethod {
598
+ customerRef: string;
599
+ paymentMethodRef: string;
600
+ }
601
+ /** A gateway callback, verified and translated. */
602
+ type PaymentGatewayEvent = {
603
+ kind: 'payment-method-confirmed';
604
+ /** The gateway's identifier of the event, unique within its account. */
605
+ eventId: string;
606
+ /** When the gateway says it happened. */
607
+ occurredAt: Date;
608
+ sessionRef: string;
609
+ subject: PaymentMethodSetupSubject;
610
+ paymentMethod: ConfirmedPaymentMethod;
611
+ } | {
612
+ kind: 'payment-method-setup-failed';
613
+ eventId: string;
614
+ occurredAt: Date;
615
+ sessionRef: string;
616
+ subject: PaymentMethodSetupSubject;
617
+ } | {
618
+ /** Genuine, and nothing SaaSiCat acts on. */
619
+ kind: 'unhandled';
620
+ eventId: string;
621
+ occurredAt: Date;
622
+ /** The gateway's own name for the event, for the log. */
623
+ type: string;
624
+ };
625
+ /**
626
+ * One account at a payment gateway: its keys, its form and its callbacks.
627
+ *
628
+ * An adapter is bound to one account, and `config/saas.yaml#payments.accounts`
629
+ * names it. Mollie or any other provider is another adapter behind this port.
630
+ */
631
+ interface PaymentGateway {
632
+ /** The provider, as `config/saas.yaml` names it, e.g. `stripe`. */
633
+ readonly provider: string;
634
+ /**
635
+ * Opens the gateway's form for a payment method. Nothing is confirmed until
636
+ * the gateway says so through `readCallback`.
637
+ */
638
+ startPaymentMethodSetup(input: StartPaymentMethodSetupInput): Promise<PaymentMethodSetupSession>;
639
+ /**
640
+ * Verifies a callback with the account's secret and translates it.
641
+ * Throws `PaymentCallbackRejectedError` for anything the gateway did not
642
+ * send, before a single field of it is trusted.
643
+ */
644
+ readCallback(callback: PaymentGatewayCallback): Promise<PaymentGatewayEvent>;
645
+ }
646
+ /**
647
+ * A callback that is not the gateway's: a missing or wrong signature, a stale
648
+ * timestamp, a body that was altered. Nothing is read from it.
649
+ */
650
+ declare class PaymentCallbackRejectedError extends Error {
651
+ readonly code = "PAYMENT_CALLBACK_REJECTED";
652
+ constructor(reason: string);
653
+ }
654
+ /** Realm-safe type guard, like `isPlatformUserExistsError`. */
655
+ declare function isPaymentCallbackRejectedError(err: unknown): err is PaymentCallbackRejectedError;
656
+
657
+ type FeatureKey = string;
658
+ type PlanId = string;
659
+ type QuotaKey = string;
660
+ interface FeatureDef {
661
+ key: FeatureKey;
662
+ label?: string;
663
+ icon?: string;
664
+ /** CORE / ADVANCED / PRO / BUSINESS / ENTERPRISE_ONLY — convention. */
665
+ tier?: string;
666
+ plannedOnly?: boolean;
667
+ }
668
+ interface PlanDef {
669
+ id: PlanId;
670
+ name?: string;
671
+ tagline?: string;
672
+ /** false = not selectable in self-service onboarding. Default: true. */
673
+ marketed?: boolean;
674
+ /** Highlighted card in onboarding (max. 1 per catalog). */
675
+ popular?: boolean;
676
+ /** Net monthly price. null = on request. */
677
+ monthlyNet?: number | null;
678
+ /** Net total amount per year. null = monthly only. */
679
+ yearlyNet?: number | null;
680
+ /** Map quotaKey → max value. -1 = unlimited. */
681
+ quotas: Record<QuotaKey, number>;
682
+ features: FeatureKey[];
683
+ }
684
+ /** App-wide marketing configuration. */
685
+ interface PlanCatalogMarketing {
686
+ /**
687
+ * Allowed language pool that the app may market. First = default
688
+ * locale. From it, the SuperAdmin activates a subset in the marketing
689
+ * catalog (LocaleManager).
690
+ */
691
+ availableLocales: string[];
692
+ }
693
+ /**
694
+ * App identity block for branding + version. Consumed by the `AdminPublicBootController`
695
+ * and the `AdminManifestConfigFactory`; the SuperAdmin UI (platform
696
+ * LoginPage, AdminLayout brand block) reads the same fields via PublicBoot.
697
+ *
698
+ * `name` = brand display name (e.g. "DemoApp", "ClubApp").
699
+ * `label` = tag/subtitle in the brand block (e.g. "SuperAdmin").
700
+ * `version` = app version string (build info).
701
+ * `icon` = 2-character abbreviation for the logo badge (e.g. "ma", "da").
702
+ * `logoUrl` = optional URL to a PNG/SVG; if set, the UI renders an <img>
703
+ * instead of the initials badge.
704
+ */
705
+ interface PlanCatalogApp {
706
+ name: string;
707
+ label?: string;
708
+ version?: string;
709
+ icon?: string;
710
+ logoUrl?: string;
711
+ }
712
+ /**
713
+ * Notice periods, one per rhythm.
714
+ *
715
+ * One number for both was the shape until 2026-08-27, and it could not be right
716
+ * for both: a yearly contract with a fortnight of notice is unusual, and a
717
+ * monthly contract with three months of notice is void against a consumer. The
718
+ * two are configured apart because real contracts set them apart.
719
+ *
720
+ * Both members are required. A missing rhythm would read as zero, and a silent
721
+ * zero is a commercial decision nobody made — the same defect one level below
722
+ * the one that moved these settings into the file.
723
+ *
724
+ * **No ceiling is enforced.** §309 Nr. 9 BGB limits the notice period in German
725
+ * consumer contracts to one month, and an installation serving businesses is
726
+ * not bound by it. The platform cannot know which it is, so the number is the
727
+ * consumer app's to choose and this is the sentence that says what it costs.
728
+ */
729
+ interface CancellationNoticePeriods {
730
+ /** Days of notice for a monthly subscription. */
731
+ monthly: number;
732
+ /** Days of notice for a yearly subscription. */
733
+ yearly: number;
734
+ }
735
+ /**
736
+ * Plans a tenant may not reach or leave without talking to sales.
737
+ *
738
+ * `asTarget`: may not be selected via self-service — typically ENTERPRISE,
739
+ * which only a special contract activates. `asSource`: may not be left via
740
+ * self-service — typically an active special contract.
741
+ *
742
+ * Both lists are required and may be empty. An empty list says out loud that
743
+ * self-service reaches every plan, which is a decision rather than an omission.
744
+ */
745
+ interface SelfServiceBlockedPlans {
746
+ asTarget: string[];
747
+ asSource: string[];
748
+ }
749
+ /**
750
+ * Commercial settings for the tenant-facing self-service routes.
751
+ *
752
+ * They live in `config/saas.yaml` and nowhere else: an operator reading the
753
+ * file has to be reading the values that are running, with no "unless somebody
754
+ * passed it in code" attached. The file is read at boot, so an edit lands on
755
+ * the next restart.
756
+ */
757
+ interface PlanCatalogTenantBilling {
758
+ cancellationNoticeDays: CancellationNoticePeriods;
759
+ selfServiceBlockedPlans: SelfServiceBlockedPlans;
760
+ }
761
+ /**
762
+ * Who is told when the settings in the file change between two starts.
763
+ *
764
+ * The record inside the application is written whether or not anybody is
765
+ * named here; mail is the addition, never the substitute. Mailed only where an
766
+ * email port is bound — without one the boot log says so once, and the change
767
+ * is recorded in the application only.
768
+ */
769
+ interface PlanCatalogNotifications {
770
+ /** Addresses mailed when a start finds the applied settings changed. */
771
+ settingsChanged?: string[];
772
+ }
773
+ /**
774
+ * The legal entity on the operator's side of every contract the installation
775
+ * concludes. A contract copies it on the day it is concluded; only the legal
776
+ * name is required while nothing is invoiced.
777
+ */
778
+ interface PlanCatalogIssuer {
779
+ legalName: string;
780
+ addressLine1?: string;
781
+ addressLine2?: string;
782
+ postalCode?: string;
783
+ city?: string;
784
+ /** ISO 3166-1 alpha-2. */
785
+ country?: string;
786
+ vatId?: string;
787
+ taxNumber?: string;
788
+ /** Declares a changed identity as a correction of the same legal entity. */
789
+ correctionOf?: PlanCatalogIssuerCorrection;
790
+ }
791
+ /**
792
+ * What a changed issuer identity replaces, and why.
793
+ *
794
+ * The identity is the counterparty a contract names, so it moves under a
795
+ * running contract only as a correction of that same entity. Each field names
796
+ * the value the installation recorded before the change, `null` where it
797
+ * recorded none; a field the change leaves alone need not be named.
798
+ */
799
+ interface PlanCatalogIssuerCorrection {
800
+ legalName?: string | null;
801
+ vatId?: string | null;
802
+ taxNumber?: string | null;
803
+ /** Why the same entity now reads differently. Kept in the settings record. */
804
+ reason: string;
805
+ }
806
+ /** How the parties contracts are concluded with are numbered. */
807
+ interface PlanCatalogSubscribers {
808
+ /**
809
+ * Put in front of every customer number assigned from the next start on.
810
+ * A number keeps the prefix it was assigned with.
811
+ */
812
+ customerNumberPrefix?: string;
813
+ }
814
+ /** One account at a payment gateway, by the name `PlanCatalogPayments.accounts` gives it. */
815
+ interface PlanCatalogPaymentAccount {
816
+ /** The provider its bound adapter names itself as, e.g. `stripe`. */
817
+ provider: string;
818
+ /** What a new payment method may be at this account. Read for `newPaymentMethods` only. */
819
+ methods?: PaymentMethodType[];
677
820
  }
678
- interface CreateMarketingProjectionData {
679
- projectKey: string;
680
- targetType: MarketingTargetType;
681
- targetVersionId: string;
682
- locale?: string;
683
- displayLabel: string;
684
- description: string;
685
- visible?: boolean;
686
- badge?: string;
687
- topFeatures?: MarketingTopFeature[];
688
- trialEnabled?: boolean;
689
- trialDays?: number;
690
- priceTag?: string | null;
691
- ctaLabel?: string | null;
692
- priority?: number;
693
- highlight?: boolean;
821
+ /**
822
+ * The gateway accounts payment methods are taken through. Their keys are bound
823
+ * in code from the environment, never written into the file.
824
+ */
825
+ interface PlanCatalogPayments {
826
+ /** The account a new payment method is taken at. Omitted, none is taken. */
827
+ newPaymentMethods?: string;
828
+ /** The origins a gateway's form may send a person back to, such as `https://app.example.com`. */
829
+ returnUrlOrigins: string[];
830
+ /** Every account taking new payment methods or holding a reference in use. */
831
+ accounts: Record<string, PlanCatalogPaymentAccount>;
694
832
  }
695
- interface UpdateMarketingProjectionData {
696
- displayLabel?: string;
697
- description?: string;
698
- visible?: boolean;
699
- badge?: string;
700
- topFeatures?: MarketingTopFeature[];
701
- trialEnabled?: boolean;
702
- trialDays?: number;
703
- priceTag?: string | null;
704
- ctaLabel?: string | null;
705
- priority?: number;
706
- highlight?: boolean;
833
+ /**
834
+ * The part of `config/saas.yaml` that is configuration rather than catalogue.
835
+ *
836
+ * Declared apart so it can be handed on whole — a database catalogue takes its
837
+ * settings from the file and its plans from the database — without anybody
838
+ * listing the blocks again. `CATALOGUE_KEYS` names what is left over.
839
+ */
840
+ interface PlanCatalogSettings {
841
+ /** App identity (branding + version), see PlanCatalogApp. */
842
+ app: PlanCatalogApp;
843
+ /** ISO-4217 currency code. */
844
+ currency: string;
845
+ /** VAT rate in percent. */
846
+ vatRate: number;
847
+ /** Commercial settings for the tenant self-service routes. */
848
+ tenantBilling: PlanCatalogTenantBilling;
849
+ /** App-wide marketing configuration. Optional. */
850
+ marketing?: PlanCatalogMarketing;
851
+ /** Who is told when the settings change between two starts. Optional. */
852
+ notifications?: PlanCatalogNotifications;
853
+ /** The operator's side of every contract. Optional until invoicing requires it. */
854
+ issuer?: PlanCatalogIssuer;
855
+ /** How subscribers are numbered. Optional. */
856
+ subscribers?: PlanCatalogSubscribers;
857
+ /** The payment gateway accounts. Optional until payment methods are taken. */
858
+ payments?: PlanCatalogPayments;
859
+ }
860
+ interface PlanCatalog extends PlanCatalogSettings {
861
+ schemaVersion: 1;
862
+ features?: FeatureDef[];
863
+ /**
864
+ * Optional. When omitted, plans come exclusively from the
865
+ * AdminUI / DB table (Plans/PlanVersions lifecycle).
866
+ */
867
+ plans?: PlanDef[];
707
868
  }
708
869
 
709
870
  type PromoCodeValueType = 'PERCENT' | 'ABSOLUTE';
@@ -902,7 +1063,7 @@ interface VersionedEntityBase {
902
1063
  * own migration).
903
1064
  * - `startedAt` is the contract start of this booking.
904
1065
  * - `minimumTermEndsAt` = end of the minimum term; `null` = no minimum term
905
- * (platform default = 12 months, set service-side).
1066
+ * (platform default = no commitment, set service-side).
906
1067
  * - `canceledAt` / `canceledEffectiveAt`: cancellation anchor vs. effective
907
1068
  * date. Before the minimum term ends, `canceledEffectiveAt =
908
1069
  * minimumTermEndsAt`, otherwise the subscription's period end.
@@ -919,6 +1080,18 @@ interface SubscriptionBundleRecord {
919
1080
  minimumTermEndsAt: Date | null;
920
1081
  canceledAt: Date | null;
921
1082
  canceledEffectiveAt: Date | null;
1083
+ /**
1084
+ * The rhythm this booking is billed in, and the window it is billed for.
1085
+ *
1086
+ * A bundle's periods end on the day its plan's do — the first one short,
1087
+ * from the booking to the next occurrence of that day, and every one after
1088
+ * it anchor to anchor. Null on a booking made before these fields existed,
1089
+ * or on one whose plan has no period; readers fall back to the plan's
1090
+ * cycle, which is what every booking used before.
1091
+ */
1092
+ billingCycle: string | null;
1093
+ currentPeriodStart: Date | null;
1094
+ currentPeriodEnd: Date | null;
922
1095
  createdAt: Date;
923
1096
  updatedAt: Date;
924
1097
  }
@@ -932,14 +1105,27 @@ interface SubscriptionBundleRecord {
932
1105
  interface SubscriptionBundleView extends SubscriptionBundleRecord {
933
1106
  bundleKey: string | null;
934
1107
  label: string | null;
935
- monthlyNet: string | null;
1108
+ /**
1109
+ * What this booking is billed at, in the rhythm it was booked in and with
1110
+ * the plan's pricing override applied.
1111
+ *
1112
+ * It was `monthlyNet` until 2026-08-27 and carried the bundle's base
1113
+ * monthly price whatever the booking was — so a yearly booking of a bundle
1114
+ * priced 10 monthly and 100 yearly reported 10. The name was half the
1115
+ * defect: a field called `monthlyNet` on a yearly booking cannot be right.
1116
+ */
1117
+ priceNet: number | null;
936
1118
  }
937
1119
  interface CreateSubscriptionBundleData {
938
1120
  subscriptionId: string;
939
1121
  bundleVersionId: string;
940
1122
  startedAt: Date;
941
- /** Default = startedAt + 12 months, unless set. */
1123
+ /** Null unless a commitment was configured or asked for. */
942
1124
  minimumTermEndsAt?: Date | null;
1125
+ /** The rhythm and window worked out above this port. */
1126
+ billingCycle?: string | null;
1127
+ currentPeriodStart?: Date | null;
1128
+ currentPeriodEnd?: Date | null;
943
1129
  }
944
1130
  interface CancelSubscriptionBundleData {
945
1131
  canceledAt: Date;
@@ -988,7 +1174,6 @@ interface BundlePricingOverride {
988
1174
  */
989
1175
  interface BundleRow {
990
1176
  id: string;
991
- projectKey: string;
992
1177
  bundleKey: string;
993
1178
  label: string;
994
1179
  description: string | null;
@@ -1027,7 +1212,6 @@ interface BundleVersionRow extends VersionedEntityBase {
1027
1212
  * first BundleVersion via `CreateBundleVersionDraftData`.
1028
1213
  */
1029
1214
  interface CreateBundleData {
1030
- projectKey: string;
1031
1215
  bundleKey: string;
1032
1216
  label: string;
1033
1217
  description?: string | null;
@@ -1036,9 +1220,9 @@ interface CreateBundleData {
1036
1220
  i18n?: CatalogEntryI18n;
1037
1221
  }
1038
1222
  /**
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.
1223
+ * Fields that may be changed on the bundle master. `bundleKey` is
1224
+ * intentionally not here — master identity is immutable; whoever wants to
1225
+ * change it creates a new bundle and retires the old one.
1042
1226
  */
1043
1227
  interface UpdateBundleData {
1044
1228
  label?: string;
@@ -1173,6 +1357,263 @@ interface BundleVersionMutationResult {
1173
1357
  warnings: StrictModeWarning[];
1174
1358
  }
1175
1359
 
1360
+ /**
1361
+ * The column values a new BundleVersion draft starts from.
1362
+ *
1363
+ * Every adapter has to apply the same defaults — an absent quota map is `{}`,
1364
+ * an absent price is null rather than zero, an unstated `marketed` is true —
1365
+ * and two adapters spelling that out separately is the same decision written
1366
+ * twice. It is also the variant jscpd does catch, which is how this came out:
1367
+ * `adapter-drizzle` learning about bundles put a second copy beside
1368
+ * `adapter-prisma`'s.
1369
+ *
1370
+ * Validity windows are deliberately absent. Whether a draft carries
1371
+ * `validFrom`/`validUntil` is an adapter capability rather than a default, and
1372
+ * an adapter that does not maintain those columns must not write them.
1373
+ */
1374
+ declare function bundleDraftDefaults(data: CreateBundleVersionDraftData): {
1375
+ baseVersionId: string | null;
1376
+ features: string[];
1377
+ quotas: Record<string, number>;
1378
+ compatibility: Record<string, unknown>;
1379
+ pricingOverrides: unknown[];
1380
+ monthlyNet: string | null;
1381
+ yearlyNet: string | null;
1382
+ marketed: boolean;
1383
+ changeNote: string;
1384
+ createdByUserId: string | null;
1385
+ };
1386
+ /**
1387
+ * The column values a new Bundle stem starts from.
1388
+ *
1389
+ * The same defaulting rule as above, one level up: an absent description or
1390
+ * icon is null rather than an empty string, an unstated sort order is 0, an
1391
+ * absent translation map is `{}`. Written out in five places before this — two
1392
+ * adapters and two fakes — which is four opportunities for one of them to
1393
+ * decide differently.
1394
+ */
1395
+ declare function bundleStemDefaults(data: CreateBundleData): {
1396
+ bundleKey: string;
1397
+ label: string;
1398
+ description: string | null;
1399
+ icon: string | null;
1400
+ sortOrder: number;
1401
+ i18n: CatalogEntryI18n;
1402
+ };
1403
+ /** The stored shape both adapters read a bundle stem back from. */
1404
+ interface StoredBundleStem {
1405
+ id: string;
1406
+ bundleKey: string;
1407
+ label: string;
1408
+ description: string | null;
1409
+ icon: string | null;
1410
+ sortOrder: number;
1411
+ i18n: unknown;
1412
+ createdAt: Date;
1413
+ updatedAt: Date;
1414
+ deletedAt: Date | null;
1415
+ }
1416
+ /**
1417
+ * A stored bundle stem as the port describes it.
1418
+ *
1419
+ * The two stores spell the columns identically, so the mapping was identical
1420
+ * too — and an identical mapping in two files is one place for a field to be
1421
+ * forgotten when the row grows. `i18n` arrives as JSON of unknown shape from
1422
+ * both, and a non-object becomes `{}` rather than reaching a caller that
1423
+ * expects a map.
1424
+ */
1425
+ declare function toBundleStemRow(row: StoredBundleStem): BundleRow;
1426
+ /**
1427
+ * The fields a caller actually gave, as a patch.
1428
+ *
1429
+ * The update DTOs in this codebase mean three different things by three
1430
+ * different values: a value changes the column, an explicit `null` clears it,
1431
+ * and an **omitted** field leaves it alone. Only the last one needs care, and
1432
+ * it was written out as `...(data.x !== undefined ? { x: data.x } : {})` more
1433
+ * than fifty times across five repositories — one decision, fifty
1434
+ * opportunities to spell it differently, and the duplication ratchet is what
1435
+ * finally pointed at it.
1436
+ *
1437
+ * `null` is deliberately kept: it is a value a caller chose, not an absence.
1438
+ */
1439
+ declare function definedFields<T extends object, K extends keyof T>(data: T, keys: readonly K[]): Partial<Pick<T, K>>;
1440
+
1441
+ /** Backend capability key, convention: domain.action[.action]. */
1442
+ type CapabilityKey = string;
1443
+ /** Frontend action-registry key. Same convention as CapabilityKey. */
1444
+ type ActionKey = CapabilityKey;
1445
+ /** Lookup key in the static extensions: map of the UI build. */
1446
+ type ComponentKey = string;
1447
+ interface AdminManifest {
1448
+ schemaVersion: 1;
1449
+ project: {
1450
+ key: string;
1451
+ displayName: string;
1452
+ /** Tag/subtitle (e.g. "SuperAdmin"). From `saas.yaml#app.label`. */
1453
+ label?: string;
1454
+ /** Short abbreviation for the logo badge (e.g. "ma", "da"). From `saas.yaml#app.icon`. */
1455
+ icon?: string;
1456
+ logoUrl?: string;
1457
+ environment?: 'production' | 'staging' | 'development';
1458
+ /**
1459
+ * Allowed locale pool from the app config (`saas.yaml`
1460
+ * `marketing.availableLocales`). First = default..
1461
+ */
1462
+ availableLocales?: string[];
1463
+ /** Default locale; equals `availableLocales[0]`. */
1464
+ defaultLocale?: string;
1465
+ };
1466
+ build: {
1467
+ platformPackageVersion: string;
1468
+ appVersion: string;
1469
+ manifestHash: string;
1470
+ };
1471
+ planCatalogSnapshot: {
1472
+ source: string;
1473
+ hash: string;
1474
+ currency: string;
1475
+ vatRate: number;
1476
+ features?: FeatureDef[];
1477
+ plans: PlanDef[];
1478
+ };
1479
+ /** Map CapabilityKey → boolean. Manifest is never a security source. */
1480
+ capabilities: Record<CapabilityKey, boolean>;
1481
+ navigation: {
1482
+ standardPages: Partial<Record<StandardPageKey, StandardPageDef>>;
1483
+ projectPages?: ProjectPageDef[];
1484
+ };
1485
+ dashboard?: {
1486
+ kpiCards?: KpiCardDef[];
1487
+ };
1488
+ tenants?: {
1489
+ columns?: TenantColumnDef[];
1490
+ actions?: TenantActionDef[];
1491
+ };
1492
+ audit?: {
1493
+ actions?: AuditActionDef[];
1494
+ };
1495
+ }
1496
+ type StandardPageKey = 'dashboard' | 'tenants' | 'subscriptions' | 'promoCodes' | 'plans' | 'audit' | 'users' | 'pilots' | 'discovery' | 'bundles' | 'marketingCatalog' | 'platformEmail' | 'platformEmailHistory' | 'settings';
1497
+ interface StandardPageDef {
1498
+ enabled: boolean;
1499
+ requiredCapability?: CapabilityKey;
1500
+ }
1501
+ interface ProjectPageDef {
1502
+ /** `<app>.<area>`, e.g. `demoapp.datev`. */
1503
+ id: string;
1504
+ label: string;
1505
+ icon?: string;
1506
+ /** Frontend route, e.g. `/admin/datev`. */
1507
+ route: string;
1508
+ navSection?: string;
1509
+ /** Lookup in the static extensions: map of the shell build. */
1510
+ componentKey: ComponentKey;
1511
+ requiredCapability?: CapabilityKey;
1512
+ prefetchOnIdle?: boolean;
1513
+ }
1514
+ interface KpiCardDef {
1515
+ id: string;
1516
+ label: string;
1517
+ /** Required path: /api/v1/admin/(extras|dashboard)/... */
1518
+ endpoint: string;
1519
+ displayHint: KpiDisplayHint;
1520
+ /** 0–100; UI sorts descending. */
1521
+ slotPriority?: number;
1522
+ requiredCapability?: CapabilityKey;
1523
+ }
1524
+ interface KpiDisplayHint {
1525
+ type: 'value' | 'value+timestamp' | 'value+spark8w' | 'value+delta';
1526
+ icon?: string;
1527
+ }
1528
+ interface TenantColumnDef {
1529
+ key: string;
1530
+ label: string;
1531
+ /** Required path: /api/v1/admin/extras/...; MUST be batch-capable, no {slug}/{tenantId}. */
1532
+ endpoint: string;
1533
+ requiredCapability?: CapabilityKey;
1534
+ }
1535
+ interface TenantActionDef {
1536
+ /** `<app>.<area>.<verb>`, e.g. `demoapp.datev.runExport`. */
1537
+ id: string;
1538
+ label: string;
1539
+ /** Lookup in the static actions: map of the shell build. */
1540
+ actionKey: ActionKey;
1541
+ requiredCapability?: CapabilityKey;
1542
+ requiresMfa?: boolean;
1543
+ confirmType?: 'none' | 'simple' | 'typed-slug' | 'typed-production' | 'date';
1544
+ }
1545
+ interface AuditActionDef {
1546
+ /** SCREAMING_SNAKE_CASE; matched to the AuditLog.action column. */
1547
+ key: string;
1548
+ label: string;
1549
+ severity?: 'info' | 'low' | 'medium' | 'high';
1550
+ }
1551
+ interface ManifestContribution {
1552
+ capabilities?: Record<CapabilityKey, boolean>;
1553
+ navigation?: {
1554
+ standardPages?: Partial<Record<StandardPageKey, StandardPageDef>>;
1555
+ projectPages?: ProjectPageDef[];
1556
+ };
1557
+ dashboard?: {
1558
+ kpiCards?: KpiCardDef[];
1559
+ };
1560
+ tenants?: {
1561
+ columns?: TenantColumnDef[];
1562
+ actions?: TenantActionDef[];
1563
+ };
1564
+ audit?: {
1565
+ actions?: AuditActionDef[];
1566
+ };
1567
+ }
1568
+ interface PublicBootResponse {
1569
+ project: {
1570
+ key: string;
1571
+ displayName: string;
1572
+ /** Tag/subtitle (e.g. "SuperAdmin"). From `saas.yaml#app.label`. */
1573
+ label?: string;
1574
+ /** Short abbreviation for the logo badge (e.g. "ma", "da"). From `saas.yaml#app.icon`. */
1575
+ icon?: string;
1576
+ logoUrl?: string;
1577
+ environment?: 'production' | 'staging' | 'development';
1578
+ };
1579
+ }
1580
+
1581
+ /** Format: 'web:<email>:<sessionId>' or 'cli:<email>:<host>'. */
1582
+ type ActorTag = string;
1583
+ interface AuditEntry {
1584
+ id: string;
1585
+ /** null = platform action without tenant context (SUPER_ADMIN). */
1586
+ tenantId: string | null;
1587
+ /** null = system / cron-triggered. */
1588
+ userId: string | null;
1589
+ /** Convenience field; backend resolves it from userId. */
1590
+ userEmail: string | null;
1591
+ /** e.g. 'Tenant', 'PromoCode', 'Subscription', 'PlanVersion', 'User'. */
1592
+ entity: string;
1593
+ entityId: string;
1594
+ /** SCREAMING_SNAKE_CASE; past-tense oriented. */
1595
+ action: string;
1596
+ /** Freely structured. Convention: { field: { old, new } } or { reason, ... }. */
1597
+ changes: Record<string, unknown> | null;
1598
+ actorTag: ActorTag | null;
1599
+ ipAddress: string | null;
1600
+ userAgent: string | null;
1601
+ createdAt: string;
1602
+ }
1603
+ interface AuditQuery {
1604
+ tenantId?: string;
1605
+ userId?: string;
1606
+ entity?: string;
1607
+ entityId?: string;
1608
+ action?: string;
1609
+ /** Wildcard-capable, e.g. 'cli:*'. */
1610
+ actorTag?: string;
1611
+ from?: string;
1612
+ to?: string;
1613
+ page?: number;
1614
+ pageSize?: number;
1615
+ }
1616
+
1176
1617
  /** Promotion type. */
1177
1618
  type PromotionType = 'percent' | 'amount' | 'intro' | 'freeMonths';
1178
1619
  /** Billing cycle for which the promotion applies. */
@@ -1201,7 +1642,6 @@ type PromotionI18n = Record<string, PromotionI18nFields>;
1201
1642
  /** Wire format of a `promotions` row. */
1202
1643
  interface PromotionRow {
1203
1644
  id: string;
1204
- projectKey: string;
1205
1645
  /** Internal label (not public). */
1206
1646
  internalLabel: string;
1207
1647
  type: PromotionType;
@@ -1227,11 +1667,7 @@ interface PromotionRow {
1227
1667
  createdAt: string;
1228
1668
  updatedAt: string;
1229
1669
  }
1230
- interface PromotionFilter {
1231
- projectKey: string;
1232
- }
1233
1670
  interface CreatePromotionData {
1234
- projectKey: string;
1235
1671
  internalLabel: string;
1236
1672
  type: PromotionType;
1237
1673
  value: PromotionValue;
@@ -1346,6 +1782,7 @@ interface CheckoutOfferPriceBreakdown {
1346
1782
  regularNet: number;
1347
1783
  /** Net total after promo. */
1348
1784
  effectiveNet: number;
1785
+ /** VAT rate in percent, as the plan catalogue names it (19 = 19 %). */
1349
1786
  vatRate: number;
1350
1787
  /** Gross total after promo. */
1351
1788
  effectiveGross: number;
@@ -1354,7 +1791,6 @@ type CheckoutOfferStatus = 'open' | 'consumed' | 'expired';
1354
1791
  /** Wire format of a `checkout_offers` row. */
1355
1792
  interface CheckoutOfferRow {
1356
1793
  id: string;
1357
- projectKey: string;
1358
1794
  /** Plan selected on the website. */
1359
1795
  planKey: string;
1360
1796
  /** Resolved plan version, if known. */
@@ -1362,7 +1798,7 @@ interface CheckoutOfferRow {
1362
1798
  billingCycle: 'monthly' | 'yearly';
1363
1799
  /** Applied promotion (active at offer time). */
1364
1800
  promotionId: string | null;
1365
- /** Redeemed promo code, if the promotion was `requiresCoupon`. */
1801
+ /** Promo code applied to the offer, as the promo module normalised it. */
1366
1802
  promoCode: string | null;
1367
1803
  /** Added bundle keys. Legacy display; V3 uses `bundleVersionIds` + `lineItems`. */
1368
1804
  bundles: string[];
@@ -1385,12 +1821,44 @@ interface CheckoutOfferRow {
1385
1821
  updatedAt: string;
1386
1822
  }
1387
1823
  interface CheckoutOfferFilter {
1388
- projectKey: string;
1389
1824
  status?: CheckoutOfferStatus;
1390
1825
  }
1391
- /** Body of `POST /public/checkout-offer` — called from the website. */
1826
+ /**
1827
+ * What a caller chooses: the body of `POST /public/checkout-offer`, and the
1828
+ * input of `CheckoutOfferService.create`.
1829
+ *
1830
+ * No amount is part of it. The plan version, the bundle prices, the promotion
1831
+ * and the promo code discount are resolved on the server, so the offer costs
1832
+ * what the catalogue says rather than what a request says.
1833
+ */
1834
+ interface CheckoutOfferSelection {
1835
+ planKey: string;
1836
+ billingCycle: 'monthly' | 'yearly';
1837
+ /** Concrete BundleVersion IDs to book with the plan. */
1838
+ bundleVersionIds?: string[];
1839
+ /** A promo code to apply; refused when the promo module cannot accept it. */
1840
+ promoCode?: string | null;
1841
+ locale?: string;
1842
+ validUntil?: string | null;
1843
+ }
1844
+ /**
1845
+ * What a caller may change while an offer is open: the body of
1846
+ * `PATCH /public/checkout-offer/:id`. The plan is fixed; everything given here
1847
+ * is priced again.
1848
+ */
1849
+ interface CheckoutOfferSelectionUpdate {
1850
+ billingCycle?: 'monthly' | 'yearly';
1851
+ bundleVersionIds?: string[];
1852
+ /** `null` removes a code applied before. */
1853
+ promoCode?: string | null;
1854
+ locale?: string;
1855
+ validUntil?: string | null;
1856
+ }
1857
+ /**
1858
+ * A new offer as the repository stores it — the selection with the amounts
1859
+ * the server computed for it (`CheckoutOfferRepository.create`).
1860
+ */
1392
1861
  interface CreateCheckoutOfferData {
1393
- projectKey: string;
1394
1862
  planKey: string;
1395
1863
  planVersionId?: string | null;
1396
1864
  billingCycle: 'monthly' | 'yearly';
@@ -1406,11 +1874,13 @@ interface CreateCheckoutOfferData {
1406
1874
  validUntil?: string | null;
1407
1875
  }
1408
1876
  /**
1409
- * Body of `PATCH /public/checkout-offer/:id` — customization during
1410
- * onboarding. `status`/`consumedAt` are not editable — `consume()`
1411
- * sets them server-side.
1877
+ * A change to an open offer as the repository stores it, with the amounts
1878
+ * priced again (`CheckoutOfferRepository.update`). `status`/`consumedAt` are
1879
+ * not editable — `consume()` sets them server-side.
1412
1880
  */
1413
1881
  interface UpdateCheckoutOfferData {
1882
+ /** The plan version active when the change was priced. */
1883
+ planVersionId?: string | null;
1414
1884
  billingCycle?: 'monthly' | 'yearly';
1415
1885
  promotionId?: string | null;
1416
1886
  promoCode?: string | null;
@@ -1426,7 +1896,6 @@ interface UpdateCheckoutOfferData {
1426
1896
 
1427
1897
  /** Wire format of the `marketing_settings` row. */
1428
1898
  interface MarketingSettingsRow {
1429
- projectKey: string;
1430
1899
  /** Runtime-activated subset of the `availableLocales` pool. */
1431
1900
  activeLocales: string[];
1432
1901
  updatedAt: string;
@@ -1462,6 +1931,10 @@ interface PublicMarketingPlan {
1462
1931
  badge: string;
1463
1932
  /** Teaser / description text. */
1464
1933
  description: string;
1934
+ /**
1935
+ * The recommended plan, and at most one card in a catalogue carries it —
1936
+ * see `keepOneRecommended`, which decides it per language served.
1937
+ */
1465
1938
  highlight: boolean;
1466
1939
  /**
1467
1940
  * Formatted pricing tag from the MarketingProjection (#47, e.g.
@@ -1544,7 +2017,6 @@ interface PublicComparisonRow {
1544
2017
  }
1545
2018
  /** Response of `GET /public/marketing-catalog`. */
1546
2019
  interface PublicMarketingCatalogResponse {
1547
- projectKey: string;
1548
2020
  locale: string;
1549
2021
  currency: string;
1550
2022
  /** VAT rate in percent — for the CheckoutOffer price breakdown. */
@@ -1654,7 +2126,7 @@ interface DiscoverySnapshot {
1654
2126
  /** ISO timestamp of the boot-time scan. */
1655
2127
  scannedAt: string;
1656
2128
  app: {
1657
- /** projectKey, same concept as in the catalog tables. */
2129
+ /** The application's name, from `saas.yaml#app.name`. */
1658
2130
  key: string;
1659
2131
  /** Backend version, e.g. from package.json. */
1660
2132
  version: string;
@@ -1690,6 +2162,18 @@ interface FeatureUiMeta {
1690
2162
  /** Map FeatureKey → UI metadata. Consumer apps supply a complete table. */
1691
2163
  type FeatureUiRegistry = Record<string, FeatureUiMeta>;
1692
2164
 
2165
+ /** A quota as a finite number, or `null` where the value cannot be read as one. */
2166
+ declare function readQuotaValue(value: unknown): number | null;
2167
+ /**
2168
+ * Every quota in a JSON column, for a caller that computes with them.
2169
+ *
2170
+ * A key that is there stays there. Dropping an unreadable one made it *absent*,
2171
+ * and absent means undeclared: `enforceLimit` answers an undeclared dimension
2172
+ * with a 500, so every operation on that quota was refused — a fail-closed
2173
+ * answer to somebody else's corrupt row.
2174
+ */
2175
+ declare function readQuotaRecord(value: unknown): Record<string, number>;
2176
+
1693
2177
  /** Alias for historical compatibility — equivalent to VersionChangeDirection. */
1694
2178
  type ChangeDirection = VersionChangeDirection;
1695
2179
  interface DiffResult {
@@ -1707,9 +2191,16 @@ type DecimalLike = number | string | {
1707
2191
  };
1708
2192
  interface PlanVersionFields {
1709
2193
  features: FeatureKey[];
1710
- maxUsers: number;
1711
- maxVehicles: number;
1712
- maxStorageGb: number;
2194
+ /**
2195
+ * Quotas of the version. -1 = unlimited; missing key = 0.
2196
+ *
2197
+ * Every key either side carries is compared. Which keys exist is the
2198
+ * installation's decision — they come from `@DefinesQuota` — so a fixed
2199
+ * set here would have compared the three the platform happened to know by
2200
+ * name and let every other one be lowered without the confirmation
2201
+ * publishing a regression asks for.
2202
+ */
2203
+ quotas: Record<QuotaKey, number>;
1713
2204
  monthlyNet: DecimalLike;
1714
2205
  yearlyNet: DecimalLike;
1715
2206
  }
@@ -1725,8 +2216,7 @@ declare function classifyPlanDiff(oldV: PlanVersionFields, newV: PlanVersionFiel
1725
2216
  /**
1726
2217
  * Classification of a BundleVersion diff for contract protection.
1727
2218
  *
1728
- * Quota comparison: `-1` (unlimited) is always better than any positive
1729
- * number. Otherwise higher = better. Missing keys are treated as 0.
2219
+ * Quotas are compared exactly as they are for a plan.
1730
2220
  *
1731
2221
  * Pricing can be `null` (the bundle only has override pricing); a switch
1732
2222
  * from value ↔ null is classified as REGRESSION (value dropped) or IMPROVEMENT
@@ -1769,6 +2259,11 @@ interface PromoPreviewValidResponse {
1769
2259
  /** Decimal-as-string, e.g. "199.00". */
1770
2260
  originalGross: string;
1771
2261
  discountGross: string;
2262
+ /**
2263
+ * `discountGross` in net, converted at the installation's VAT rate —
2264
+ * the figure a page showing net prices takes off the plan price.
2265
+ */
2266
+ discountNet: string;
1772
2267
  discountedGross: string;
1773
2268
  includedVat: string;
1774
2269
  nextRegularAmountGross: string;
@@ -1824,9 +2319,94 @@ interface OnboardingPromoRedemption {
1824
2319
  endsAt: string | null;
1825
2320
  }
1826
2321
 
2322
+ /**
2323
+ * The settings subtree of a plan catalogue: every top-level block that is
2324
+ * configuration rather than the catalogue itself. JSON-shaped, because it is
2325
+ * stored as JSON and compared as JSON.
2326
+ */
2327
+ type AppliedSettingsValues = Record<string, unknown>;
2328
+ /** The one row per installation: what is applied, since when, and from where. */
2329
+ interface AppliedSettingsRecord {
2330
+ /**
2331
+ * `sha256-<hex>` over the canonical JSON of `settings`. Two boots with the
2332
+ * same resolved values produce the same fingerprint however the file was
2333
+ * formatted, and a plan added to the catalogue does not move it.
2334
+ */
2335
+ fingerprint: string;
2336
+ settings: AppliedSettingsValues;
2337
+ /**
2338
+ * Where the values came from: the absolute path of the file the platform
2339
+ * read, or a phrase saying they were handed to it in code.
2340
+ */
2341
+ source: string;
2342
+ /** The moment these values became the running configuration. */
2343
+ appliedAt: Date;
2344
+ }
2345
+ /** What a boot noticed had changed since the previous record. */
2346
+ interface SettingsChangeRecord {
2347
+ id: string;
2348
+ /** The boot that noticed the difference and applied the new values. */
2349
+ noticedAt: Date;
2350
+ source: string;
2351
+ previous: AppliedSettingsValues;
2352
+ current: AppliedSettingsValues;
2353
+ /** Set once an operator has seen it; null while it is still owed a look. */
2354
+ acknowledgedAt: Date | null;
2355
+ /** Who acknowledged it — an actor tag, as the audit log writes it. */
2356
+ acknowledgedBy: string | null;
2357
+ }
2358
+ type NewSettingsChange = Pick<SettingsChangeRecord, 'noticedAt' | 'source' | 'previous' | 'current'>;
2359
+ /** One leaf that differs between two settings subtrees. */
2360
+ interface SettingsDifference {
2361
+ /** Dotted path, as the loader names a field: `tenantBilling.cancellationNoticeDays.monthly`. */
2362
+ path: string;
2363
+ /** `undefined` where the leaf did not exist on that side. */
2364
+ before: unknown;
2365
+ after: unknown;
2366
+ }
2367
+
2368
+ /**
2369
+ * The top-level blocks of `config/saas.yaml` that are the catalogue rather than
2370
+ * the configuration, and the format marker.
2371
+ *
2372
+ * An exclusion list rather than a list of settings, on purpose: a block the
2373
+ * schema gains tomorrow is a setting until somebody says otherwise, so it is
2374
+ * fingerprinted by default. The failure mode of the other list — a new setting
2375
+ * silently left out of the fingerprint, so a change to it is never noticed — is
2376
+ * the one this record exists to prevent. `schemaVersion` is excluded because a
2377
+ * format change is a migration of the file, not a decision an operator took.
2378
+ *
2379
+ * `tests/settings-subtree.test.js` holds this list to the schema in both
2380
+ * directions: every name here is a property the schema declares, and every
2381
+ * property the schema declares lands on one side.
2382
+ */
2383
+ declare const CATALOGUE_KEYS: ReadonlySet<keyof PlanCatalog>;
2384
+ /**
2385
+ * The settings of a catalogue, typed, for handing them on as a whole.
2386
+ *
2387
+ * The same selection as `settingsSubtreeOf`, which is why it is that function:
2388
+ * a caller that listed the blocks it passes on would drop the next one the
2389
+ * schema gains, and nothing would say so.
2390
+ */
2391
+ declare function planCatalogSettingsOf(catalog: PlanCatalog): PlanCatalogSettings;
2392
+ /** Everything in the catalogue that is configuration, as it was resolved. */
2393
+ declare function settingsSubtreeOf(catalog: PlanCatalog): AppliedSettingsValues;
2394
+ /**
2395
+ * `JSON.stringify` with object keys in sorted order at every depth, so that two
2396
+ * documents saying the same thing in a different order serialise identically.
2397
+ * Array order is kept: a list is what its author wrote, in the order they wrote
2398
+ * it.
2399
+ */
2400
+ declare function canonicalJson(value: unknown): string;
2401
+ /**
2402
+ * Every leaf that differs between `before` and `after`, in the order the paths
2403
+ * sort. A list counts as one leaf: `asTarget: [] → [ENTERPRISE]` is one thing
2404
+ * that changed, not a change per element.
2405
+ */
2406
+ declare function diffSettings(before: AppliedSettingsValues, after: AppliedSettingsValues): SettingsDifference[];
2407
+
1827
2408
  interface PlanRow {
1828
2409
  id: string;
1829
- projectKey: string;
1830
2410
  planKey: string;
1831
2411
  label: string;
1832
2412
  description: string | null;
@@ -1843,7 +2423,6 @@ interface PlanRow {
1843
2423
  * creation (follows in M6 Pack 2).
1844
2424
  */
1845
2425
  interface CreatePlanData {
1846
- projectKey: string;
1847
2426
  planKey: string;
1848
2427
  label: string;
1849
2428
  description?: string | null;
@@ -1851,9 +2430,9 @@ interface CreatePlanData {
1851
2430
  sortOrder?: number;
1852
2431
  }
1853
2432
  /**
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.
2433
+ * Fields that may be changed on the plan stem. `planKey` is deliberately not
2434
+ * here — stem identity is immutable; whoever wants to change it creates a new
2435
+ * plan and retires the old one.
1857
2436
  */
1858
2437
  interface UpdatePlanData {
1859
2438
  label?: string;
@@ -1917,7 +2496,6 @@ interface UpsertResult {
1917
2496
  skipReason?: string;
1918
2497
  }
1919
2498
  interface UpsertPlanInput {
1920
- projectKey: string;
1921
2499
  planKey: string;
1922
2500
  label: string;
1923
2501
  description?: string | null;
@@ -1937,7 +2515,6 @@ interface UpsertPlanVersionInput {
1937
2515
  changeNote: string;
1938
2516
  }
1939
2517
  interface UpsertFeatureCatalogEntryInput {
1940
- projectKey: string;
1941
2518
  featureKey: FeatureKey;
1942
2519
  label?: string;
1943
2520
  icon?: string;
@@ -1983,7 +2560,7 @@ interface PlanCatalogReadSnapshot {
1983
2560
  * implement it against their Prisma tables.
1984
2561
  */
1985
2562
  interface PlanCatalogReadSink {
1986
- loadSnapshot(projectKey: string): Promise<PlanCatalogReadSnapshot>;
2563
+ loadSnapshot(): Promise<PlanCatalogReadSnapshot>;
1987
2564
  }
1988
2565
 
1989
2566
  /**
@@ -2220,6 +2797,26 @@ interface PasswordHasher {
2220
2797
  hash(plain: string): Promise<string>;
2221
2798
  verify(hash: string, plain: string): Promise<boolean>;
2222
2799
  }
2800
+ /**
2801
+ * Sends a plain-text mail to an operator.
2802
+ *
2803
+ * The platform composes the text; the adapter delivers it — over whatever the
2804
+ * installation already sends mail with. Deliberately narrow: no templates, no
2805
+ * locale, no HTML. The one thing the platform mails today is a diagnostic for
2806
+ * the operator who runs the installation, and a diagnostic is English and
2807
+ * plain, like the boot log it mirrors. Tenant-facing mail — a verification
2808
+ * code, a resume link — goes through the registration module's own delivery
2809
+ * ports, which carry the locale and the person's name because that mail is
2810
+ * for a customer.
2811
+ */
2812
+ interface EmailPort {
2813
+ /** Delivers one plain-text mail to one address; rejects when it cannot. */
2814
+ send(message: {
2815
+ to: string;
2816
+ subject: string;
2817
+ text: string;
2818
+ }): Promise<void>;
2819
+ }
2223
2820
  /** Adapter for MFA secret persistence. */
2224
2821
  interface MfaPort {
2225
2822
  /** Returns the stored TOTP secret or null. */
@@ -2427,6 +3024,26 @@ interface PromoRevenueDeductionAggregator {
2427
3024
 
2428
3025
  type ContractLineItemKind = 'plan' | 'bundle' | 'discount';
2429
3026
  type SubscriptionContractStatus = 'active' | 'scheduled' | 'terminated' | 'superseded';
3027
+ /**
3028
+ * The statuses a contract is looked up under when asking "what is this tenant
3029
+ * on right now" — `scheduled` included, because a contract that starts today
3030
+ * and has not been switched to `active` yet is still the one in force at its
3031
+ * own `effectiveFrom`.
3032
+ *
3033
+ * One list rather than one per adapter: the two adapters have to answer
3034
+ * `findActiveByTenantId` the same way, and a status added here must not reach
3035
+ * only whichever of them somebody remembered.
3036
+ */
3037
+ /**
3038
+ * How many bundle versions one price lookup may name.
3039
+ *
3040
+ * One number rather than two: the server validates against it and the client
3041
+ * batches to stay inside it, and a client that learned the cap by receiving a
3042
+ * 400 would fail silently — the lookup answers with an empty map, and every
3043
+ * card falls back to a catalogue price the tenant may not be charged.
3044
+ */
3045
+ declare const BUNDLE_PRICE_LOOKUP_LIMIT = 200;
3046
+ declare const ACTIVE_SUBSCRIPTION_CONTRACT_STATUSES: readonly SubscriptionContractStatus[];
2430
3047
  interface ContractLineItemRecord {
2431
3048
  id: string;
2432
3049
  contractId: string;
@@ -2440,6 +3057,26 @@ interface ContractLineItemRecord {
2440
3057
  priceNet: number;
2441
3058
  priceGross: number;
2442
3059
  billingCycle: 'monthly' | 'yearly';
3060
+ /**
3061
+ * ISO 4217, as the line was booked in.
3062
+ *
3063
+ * An installation sells in one currency at a time, so this is never a
3064
+ * choice the line makes — it is what keeps the line meaning what it meant
3065
+ * after the configured currency is migrated to another one.
3066
+ */
3067
+ currency: string;
3068
+ /**
3069
+ * The tax rate in percent that was applied, recorded rather than left in
3070
+ * the ratio between net and gross. That ratio is not the rate: it cannot be
3071
+ * reproduced for a rounded gross, cannot express an exempt or reverse-charge
3072
+ * line, and does not survive a rate change.
3073
+ */
3074
+ taxRate: number;
3075
+ /**
3076
+ * The tax contained in the line — exactly `priceGross - priceNet`, so the
3077
+ * line cannot disagree with itself. Rounded once, when the line is written.
3078
+ */
3079
+ taxAmount: number;
2443
3080
  minimumTermUntil: Date | null;
2444
3081
  featuresSnapshot: string[];
2445
3082
  quotaEffectsSnapshot: Record<string, number>;
@@ -2452,13 +3089,47 @@ interface SubscriptionContractPriceSnapshot {
2452
3089
  subtotalNet: number;
2453
3090
  discountNet: number;
2454
3091
  totalNet: number;
3092
+ /**
3093
+ * The tax rate this contract's total was computed at, as a percentage:
3094
+ * 19 means 19 %, as every tax rate in SaaSiCat is.
3095
+ */
2455
3096
  vatRate: number;
2456
3097
  totalGross: number;
2457
3098
  }
2458
- interface SubscriptionContractRecord {
3099
+ /**
3100
+ * The subscriber as a contract copied it on the day it was concluded.
3101
+ *
3102
+ * The invoice email is not part of it: it says how the party is reached, not
3103
+ * who the party is, and a contract is kept for years after an address like that
3104
+ * stopped mattering.
3105
+ */
3106
+ interface ContractSubscriberParty extends LegalIdentity, PartyAddress {
3107
+ customerNumber: string;
3108
+ }
3109
+ /** The issuer as `config/saas.yaml` named it on the day a contract was concluded. */
3110
+ interface ContractIssuerParty extends LegalIdentity, PartyAddress {
3111
+ }
3112
+ /** Who a contract is between, copied when it is concluded. */
3113
+ interface SubscriptionContractParties {
3114
+ subscriberId: string;
3115
+ subscriber: ContractSubscriberParty;
3116
+ /** `null` where `config/saas.yaml` named no issuer that day. */
3117
+ issuer: ContractIssuerParty | null;
3118
+ }
3119
+ interface SubscriptionContractRecord extends SubscriptionContractParties {
2459
3120
  id: string;
2460
- projectKey: string;
3121
+ /**
3122
+ * The tenant the contract was concluded for, kept as a trace. The contract
3123
+ * belongs to its subscriber and outlives the tenant.
3124
+ */
2461
3125
  tenantId: string;
3126
+ /**
3127
+ * The parties were copied by the migration that attached contracts
3128
+ * concluded before subscribers existed, not on the day the contract was
3129
+ * concluded. Either party may have changed in between, so such a copy is
3130
+ * never presented as what was agreed.
3131
+ */
3132
+ partiesMigrated: boolean;
2462
3133
  status: SubscriptionContractStatus;
2463
3134
  effectiveFrom: Date;
2464
3135
  effectiveUntil: Date | null;
@@ -2475,8 +3146,12 @@ interface SubscriptionContractRecord {
2475
3146
  updatedAt: Date;
2476
3147
  }
2477
3148
  type NewContractLineItemData = Omit<ContractLineItemRecord, 'id' | 'contractId' | 'createdAt'>;
3149
+ /**
3150
+ * A contract as a caller asks for it. The parties are not among it: the
3151
+ * platform copies them from the tenant's subscriber and the configuration when
3152
+ * the contract is written, so no caller can name a party of its own.
3153
+ */
2478
3154
  interface CreateSubscriptionContractData {
2479
- projectKey: string;
2480
3155
  tenantId: string;
2481
3156
  status?: SubscriptionContractStatus;
2482
3157
  effectiveFrom: Date;
@@ -2491,16 +3166,53 @@ interface CreateSubscriptionContractData {
2491
3166
  termsSnapshot?: Record<string, unknown> | null;
2492
3167
  lineItems: NewContractLineItemData[];
2493
3168
  }
3169
+ /** What a repository writes: the contract as asked for, with the parties the platform copied. */
3170
+ interface NewSubscriptionContractData extends CreateSubscriptionContractData {
3171
+ parties: SubscriptionContractParties;
3172
+ }
2494
3173
  interface TerminateSubscriptionContractData {
2495
3174
  effectiveUntil: Date;
2496
- status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'>;
3175
+ /**
3176
+ * The terminal status, or `null` to end the contract by date alone.
3177
+ *
3178
+ * `findActiveByTenantId` already asks its question as a window —
3179
+ * `effectiveFrom <= asOf` and `effectiveUntil` null or after it — so a
3180
+ * contract given an end in the FUTURE is found until that moment and not
3181
+ * afterwards, with no scheduled job to flip anything.
3182
+ *
3183
+ * Writing a terminal status instead makes the contract disappear from that
3184
+ * lookup at once, which for a cancellation declared months ahead removes an
3185
+ * agreement the customer is still under. Null is how a caller says "it ends
3186
+ * then", and a status is how it says "it is over now".
3187
+ */
3188
+ status: Extract<SubscriptionContractStatus, 'terminated' | 'superseded'> | null;
2497
3189
  }
2498
3190
  interface SubscriptionContractFilter {
2499
- projectKey?: string;
2500
3191
  tenantId?: string;
2501
3192
  status?: SubscriptionContractStatus;
2502
3193
  asOf?: Date;
2503
3194
  }
3195
+ /** One contract still running, and the issuer it was concluded under. */
3196
+ interface RunningContractIssuer {
3197
+ id: string;
3198
+ /** The tenant it was concluded for, kept as a trace. */
3199
+ tenantId: string;
3200
+ /**
3201
+ * The legal name on the issuer copy, or `null` where the contract names no
3202
+ * issuer — it was concluded while `config/saas.yaml` named none, or its
3203
+ * party copy was made by the migration that attached contracts concluded
3204
+ * before subscribers existed.
3205
+ */
3206
+ issuerLegalName: string | null;
3207
+ effectiveFrom: Date;
3208
+ }
3209
+ /** How many contracts are concluded and not yet over, and the first few of them. */
3210
+ interface RunningContractIssuers {
3211
+ /** All of them, whether or not the list below holds them all. */
3212
+ total: number;
3213
+ /** At most the limit the caller asked for, oldest first. */
3214
+ contracts: RunningContractIssuer[];
3215
+ }
2504
3216
  interface InvoiceLineItemSnapshot {
2505
3217
  sourceContractLineItemId: string;
2506
3218
  sourceKey: string;
@@ -2513,12 +3225,14 @@ interface InvoiceLineItemSnapshot {
2513
3225
  priceNet: number;
2514
3226
  priceGross: number;
2515
3227
  billingCycle: 'monthly' | 'yearly';
3228
+ currency: string;
3229
+ taxRate: number;
3230
+ taxAmount: number;
2516
3231
  minimumTermUntil: Date | null;
2517
3232
  metadata: Record<string, unknown> | null;
2518
3233
  }
2519
3234
  interface SubscriptionContractInvoiceSnapshot {
2520
3235
  contractId: string;
2521
- projectKey: string;
2522
3236
  tenantId: string;
2523
3237
  originalOfferId: string | null;
2524
3238
  currency: string;
@@ -2533,6 +3247,100 @@ interface SubscriptionContractInvoiceSnapshot {
2533
3247
  lineItems: InvoiceLineItemSnapshot[];
2534
3248
  }
2535
3249
 
3250
+ /** How a subscriber is reached, which may change at any time. */
3251
+ interface SubscriberContact extends PartyAddress {
3252
+ /** Where invoices will be sent. */
3253
+ invoiceEmail: string | null;
3254
+ }
3255
+ /** A subscriber's master data, as it stands. */
3256
+ type SubscriberDetails = LegalIdentity & SubscriberContact;
3257
+ /**
3258
+ * What a subscriber is created with.
3259
+ *
3260
+ * Only the legal name is required. The address, the tax identifiers and the
3261
+ * invoice email stay optional until sign-up asks for them and invoicing
3262
+ * requires them; an absent or blank value is recorded as unknown.
3263
+ */
3264
+ type NewSubscriberDetails = Pick<SubscriberDetails, 'legalName'> & Partial<Omit<SubscriberDetails, 'legalName'>>;
3265
+ interface SubscriberRecord extends SubscriberDetails {
3266
+ id: string;
3267
+ /**
3268
+ * Assigned when the subscriber is created and never changed: the number,
3269
+ * counted per installation, behind the prefix configured at that moment.
3270
+ */
3271
+ customerNumber: string;
3272
+ /** The tenant this subscriber is live for, or `null` once it has none. */
3273
+ tenantId: string | null;
3274
+ /**
3275
+ * Created by the migration that gave every existing tenant its subscriber,
3276
+ * from the application's own tenant record rather than from what a customer
3277
+ * entered.
3278
+ */
3279
+ migrated: boolean;
3280
+ createdAt: Date;
3281
+ updatedAt: Date;
3282
+ }
3283
+ /** What a repository writes for a new subscriber, every detail already settled. */
3284
+ interface CreateSubscriberData extends SubscriberDetails {
3285
+ /** The tenant the subscriber is created for and live with. */
3286
+ tenantId: string;
3287
+ /** Put in front of the assigned number; empty for the number alone. */
3288
+ customerNumberPrefix: string;
3289
+ }
3290
+ /** Contact details to change; a member left out keeps its value. */
3291
+ type SubscriberContactChange = Partial<SubscriberContact>;
3292
+ /**
3293
+ * A change to a subscriber's legal identity, and what the operator declares it
3294
+ * to be.
3295
+ *
3296
+ * SaaSiCat cannot tell a misspelt name from another company taking over, so the
3297
+ * operator says which: `correction` for the same legal entity — a typo, a wrong
3298
+ * tax identifier, a change of name that entity went through — and `takeover`
3299
+ * for another one, which is a transfer rather than an edit and is refused.
3300
+ */
3301
+ interface SubscriberIdentityCorrection {
3302
+ kind: 'correction' | 'takeover';
3303
+ legalName?: string;
3304
+ vatId?: string | null;
3305
+ taxNumber?: string | null;
3306
+ /** Why the identity is corrected. Part of the record. */
3307
+ reason: string;
3308
+ /** Who corrects it, as an actor tag the audit log would write. */
3309
+ correctedBy: string;
3310
+ }
3311
+ /** Identity values by field, holding only the fields a correction changed. */
3312
+ type SubscriberIdentityValues = Partial<LegalIdentity>;
3313
+ /** What a correction replaces and what it writes, holding only the fields that move. */
3314
+ interface SubscriberIdentityDelta {
3315
+ previous: SubscriberIdentityValues;
3316
+ corrected: SubscriberIdentityValues;
3317
+ }
3318
+ /** What a repository writes for a correction the service has accepted. */
3319
+ interface SubscriberCorrectionData {
3320
+ /** The new values; a field equal to what is stored is not recorded. */
3321
+ corrected: SubscriberIdentityValues;
3322
+ reason: string;
3323
+ correctedBy: string;
3324
+ correctedAt: Date;
3325
+ }
3326
+ /** One correction of a subscriber's legal identity, as it was recorded. */
3327
+ interface SubscriberCorrectionRecord {
3328
+ id: string;
3329
+ subscriberId: string;
3330
+ /** The values the correction replaced. */
3331
+ previous: SubscriberIdentityValues;
3332
+ /** The values it wrote. */
3333
+ corrected: SubscriberIdentityValues;
3334
+ reason: string;
3335
+ correctedBy: string;
3336
+ correctedAt: Date;
3337
+ }
3338
+ /** The outcome of writing a correction: nothing is recorded when no value moved. */
3339
+ interface SubscriberCorrectionResult {
3340
+ subscriber: SubscriberRecord;
3341
+ correction: SubscriberCorrectionRecord | null;
3342
+ }
3343
+
2536
3344
  /**
2537
3345
  * Snapshot form of a `Subscription` row for the EntitlementService
2538
3346
  * computation. The consumer maps its Prisma structure onto this form.
@@ -2552,6 +3360,24 @@ interface SubscriptionRecord {
2552
3360
  } | null;
2553
3361
  planVersionId: string;
2554
3362
  planVersion: PlanVersionRecord;
3363
+ /**
3364
+ * When a cancellation was declared, and when it takes effect.
3365
+ *
3366
+ * Required, and required together, because entitlement resolution ends a
3367
+ * subscription by reading them: without the second date it cannot tell a
3368
+ * subscription that ends next January from one that ended last January, and
3369
+ * it grants the latter everything. Nothing else in the platform would
3370
+ * notice — no repository filters a cancelled subscription out, and stopping
3371
+ * the billing period is a different decision from ending what a tenant may
3372
+ * do.
3373
+ *
3374
+ * `null` on both means no cancellation. On a row written before the two
3375
+ * fields separated, `canceledAt` carries the effective date and
3376
+ * `canceledEffectiveAt` is genuinely null; every reader in the platform
3377
+ * applies `canceledEffectiveAt ?? canceledAt` for that reason.
3378
+ */
3379
+ canceledAt: Date | null;
3380
+ canceledEffectiveAt: Date | null;
2555
3381
  }
2556
3382
  /** Snapshot of a `PlanVersion` row. */
2557
3383
  interface PlanVersionRecord {
@@ -2608,11 +3434,10 @@ interface SubscriptionRepository {
2608
3434
  countByBundleVersionId?(bundleVersionId: string): Promise<number>;
2609
3435
  /**
2610
3436
  * 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`).
3437
+ * platform-wide across every tenant — feeds the tenant column of the
3438
+ * SuperAdmin plan list (`GET /admin/catalog/plans/tenant-counts`).
2613
3439
  * Cross-version: counts the plan, not a single PlanVersion
2614
- * (subscriptions on superseded versions are included). `projectKey` is
2615
- * informational for single-project consumers.
3440
+ * (subscriptions on superseded versions are included).
2616
3441
  *
2617
3442
  * Returns a map `planKey → count`; plans without an active subscription
2618
3443
  * are missing (UI defaults to 0). Platform-wide count across all tenants →
@@ -2620,7 +3445,7 @@ interface SubscriptionRepository {
2620
3445
  *
2621
3446
  * Optional — if not implemented, the tenant column stays 0.
2622
3447
  */
2623
- countActiveByPlanKey?(projectKey: string): Promise<Record<string, number>>;
3448
+ countActiveByPlanKey?(): Promise<Record<string, number>>;
2624
3449
  }
2625
3450
  /**
2626
3451
  * Adapter for the `subscription_bundles` junction.
@@ -2679,8 +3504,89 @@ interface SubscriptionContractRepository {
2679
3504
  * instead of drawing an extra pool connection (starvation guard, #70).
2680
3505
  */
2681
3506
  findActiveByTenantId(tenantId: string, asOf?: Date, tx?: TransactionContext): Promise<SubscriptionContractRecord | null>;
2682
- create(data: CreateSubscriptionContractData): Promise<SubscriptionContractRecord>;
3507
+ /**
3508
+ * Writes the contract with the parties it names. With `tx`, the contract is
3509
+ * written on that transaction and undone with it.
3510
+ */
3511
+ create(data: NewSubscriptionContractData, tx?: TransactionContext): Promise<SubscriptionContractRecord>;
3512
+ /**
3513
+ * The contract concluded from a checkout offer (`originalOfferId`), or
3514
+ * `null` when none was. An offer is consumed once and so yields one
3515
+ * contract (`SC-MKT-017`); where an application's own path wrote two, the
3516
+ * earliest is returned, so the answer does not depend on read order.
3517
+ */
3518
+ findByOriginalOfferId(offerId: string, tx?: TransactionContext): Promise<SubscriptionContractRecord | null>;
2683
3519
  terminate(contractId: string, data: TerminateSubscriptionContractData): Promise<SubscriptionContractRecord>;
3520
+ /**
3521
+ * The contracts concluded and not yet over, with the issuer each was
3522
+ * concluded under: how many there are, and the first `limit` of them,
3523
+ * oldest first. `limit` caps the list and not the count, so a start refused
3524
+ * over a changed issuer identity says how many contracts it means before it
3525
+ * names any of them.
3526
+ *
3527
+ * Running means `active` or `scheduled` AND not ended at `asOf` — the same
3528
+ * window `findActiveByTenantId` uses on its upper end, and for the same
3529
+ * reason. Status alone is not enough: an ordinary cancellation lands at the
3530
+ * term end and writes only `effectiveUntil`, leaving the status where it
3531
+ * was, and nothing flips it when that day arrives. Counting by status would
3532
+ * therefore report every customer who ever left as still running — and
3533
+ * because the list is oldest first, the ones it names would be exactly the
3534
+ * expired ones.
3535
+ *
3536
+ * The window is open at the bottom on purpose: a contract that starts next
3537
+ * month is concluded, its party copy is fixed, and it will be invoiced under
3538
+ * the issuer it names.
3539
+ *
3540
+ * Platform-wide: unlike every other read here it is anchored by no tenant,
3541
+ * no contract and no offer, and a start makes it before anything is served.
3542
+ * An implementation on a tenant-scoped client must count RLS-exempt, as
3543
+ * `countActiveByPlanKey` must — the platform wraps the call in
3544
+ * `RlsBypassPort`, and one that answers with the caller's tenant scope
3545
+ * instead returns nothing at a boot, where there is no tenant. The
3546
+ * persistence contract runs with no policy forced, so it cannot catch that
3547
+ * for you.
3548
+ *
3549
+ * `limit` may be `0`, and a caller that wants only the count passes it:
3550
+ * `total` is exact whatever the limit, so nothing has to come back for it.
3551
+ * Zero means zero — an implementation that reads a falsy limit as "no limit"
3552
+ * returns every running contract to a caller asking for none, which is the
3553
+ * one shape of this method that gets slower the more an installation sells.
3554
+ * The executable contract asks for `0`.
3555
+ */
3556
+ listRunningIssuers(limit: number, asOf?: Date): Promise<RunningContractIssuers>;
3557
+ }
3558
+ /**
3559
+ * The parties contracts are concluded with, their link to the tenant they are
3560
+ * live for, and the corrections of their legal identity.
3561
+ *
3562
+ * A subscriber has at most one live tenant and a tenant at most one live
3563
+ * subscriber; the database holds both, so two callers creating one for the
3564
+ * same tenant at once end with one.
3565
+ */
3566
+ interface SubscriberRepository {
3567
+ /**
3568
+ * Creates a subscriber, assigns its customer number, and makes it the
3569
+ * tenant's live subscriber — all or nothing. `null` when the tenant already
3570
+ * has a live subscriber, in which case nothing is written and the caller's
3571
+ * transaction stays usable.
3572
+ */
3573
+ createForTenant(data: CreateSubscriberData, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3574
+ findById(subscriberId: string, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3575
+ /** The subscriber live for this tenant, or `null` when it has none. */
3576
+ findByTenantId(tenantId: string, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3577
+ /** Writes the members given and keeps the rest; `null` when no such subscriber exists. */
3578
+ updateContact(subscriberId: string, change: SubscriberContactChange, tx?: TransactionContext): Promise<SubscriberRecord | null>;
3579
+ /**
3580
+ * Writes a correction of the legal identity and records it, in one step:
3581
+ * the subscriber is read and changed under a lock, so the values recorded
3582
+ * as replaced are the ones this write replaced even when two corrections
3583
+ * arrive at once. A field whose stored value already equals the corrected
3584
+ * one is left out of the record, and when none differs nothing is written
3585
+ * and `correction` is `null`. `null` when no such subscriber exists.
3586
+ */
3587
+ correctIdentity(subscriberId: string, data: SubscriberCorrectionData, tx?: TransactionContext): Promise<SubscriberCorrectionResult | null>;
3588
+ /** Every correction of this subscriber, the latest first. */
3589
+ listCorrections(subscriberId: string): Promise<SubscriberCorrectionRecord[]>;
2684
3590
  }
2685
3591
  /**
2686
3592
  * Display form of a subscription for the tenant self-service UI.
@@ -2712,6 +3618,40 @@ interface SubscriptionUsageRecord {
2712
3618
  /** Current period window — for proration and change-effective date. */
2713
3619
  currentPeriodStart: Date | null;
2714
3620
  currentPeriodEnd: Date | null;
3621
+ /**
3622
+ * End of what was committed to, which the period end need not equal.
3623
+ *
3624
+ * The cancellation rules measure against this: a subscription cancelled
3625
+ * inside its term keeps running until the term ends, not until the period
3626
+ * does. Null on a trial, and on any subscription written before the field
3627
+ * existed — readers treat that as "the period end is the answer".
3628
+ */
3629
+ minimumTermUntil?: Date | null;
3630
+ /**
3631
+ * The day of the month the subscription is billed on, 1–31.
3632
+ *
3633
+ * Read by the cancellation rules: a declaration after the notice window
3634
+ * lands one period past the term end, and that step has to measure from the
3635
+ * billing day rather than from a term end that may already have been
3636
+ * clamped by a short month.
3637
+ *
3638
+ * Optional, because an adapter that does not store the column keeps today's
3639
+ * behaviour — the step then takes its day from the term end, which is
3640
+ * correct except in the month after a clamp.
3641
+ */
3642
+ billingAnchorDay?: number | null;
3643
+ /**
3644
+ * When a cancellation was declared, and when it lands.
3645
+ *
3646
+ * Required for the reason the same pair is required on
3647
+ * `SubscriptionRecord`: the tenant billing route reads them to refuse a
3648
+ * plan change on a subscription that has ended, and a record that omits
3649
+ * them answers "not cancelled" — so the change is applied and prorated
3650
+ * while entitlement resolution, which reads a record that does carry them,
3651
+ * grants nothing.
3652
+ */
3653
+ canceledAt: Date | null;
3654
+ canceledEffectiveAt: Date | null;
2715
3655
  pendingPlan: string | null;
2716
3656
  pendingBillingCycle: string | null;
2717
3657
  pendingEffectiveAt: Date | null;
@@ -2787,12 +3727,29 @@ interface ImmediatePlanChangeInput {
2787
3727
  * change, or target package without trial). A `Date` is persisted.
2788
3728
  */
2789
3729
  trialEndsAt?: Date | null;
3730
+ /**
3731
+ * `canceledAt` as the caller read it, so the write can claim the row only
3732
+ * while that is still true.
3733
+ *
3734
+ * Three of the plan route's decisions depend on the cancellation — whether
3735
+ * the change is refused at all, whether the billing cycle may move, and
3736
+ * whether a fresh period is opened — and a read and a write are two
3737
+ * moments. A cancellation declared in between made every one of them answer
3738
+ * about a state that no longer existed, and the write went ahead anyway: a
3739
+ * plan term recorded past the date the subscription ends.
3740
+ *
3741
+ * `null` is a value here rather than an absence. It claims a row that has
3742
+ * no cancellation, and loses against one that has acquired one.
3743
+ */
3744
+ expectedCanceledAt: Date | null;
2790
3745
  }
2791
3746
  /** Input for `schedulePlanChange` (change at period end). */
2792
3747
  interface ScheduledPlanChangeInput {
2793
3748
  pendingPlan: string;
2794
3749
  pendingBillingCycle: string;
2795
3750
  pendingEffectiveAt: Date;
3751
+ /** See `ImmediatePlanChangeInput.expectedCanceledAt`. */
3752
+ expectedCanceledAt: Date | null;
2796
3753
  }
2797
3754
  /**
2798
3755
  * Input for `applyOnboardingSelection`. Plan-change fields that the
@@ -2801,6 +3758,12 @@ interface ScheduledPlanChangeInput {
2801
3758
  interface ApplyOnboardingSelectionInput {
2802
3759
  planId: string;
2803
3760
  cycle: string;
3761
+ /**
3762
+ * See `ImmediatePlanChangeInput.expectedCanceledAt`. The atomic path needs
3763
+ * it for the same reason the sequential one does: without it, the preferred
3764
+ * implementation is the one where the race stays open.
3765
+ */
3766
+ expectedCanceledAt: Date | null;
2804
3767
  /** For TRIAL → null, otherwise period start from `initialPeriodWindow`. */
2805
3768
  periodStart: Date | null;
2806
3769
  periodEnd: Date | null;
@@ -2817,6 +3780,12 @@ interface ApplyOnboardingSelectionResult {
2817
3780
  subscriptionId: string;
2818
3781
  /** null if no redeemPromo callback was provided or the callback returned null. */
2819
3782
  promoRedemption: PromoCodeRedemptionRecord | null;
3783
+ /**
3784
+ * False when the row's cancellation moved since the caller read it, in
3785
+ * which case nothing was written — including the promo redemption, which
3786
+ * shares the transaction.
3787
+ */
3788
+ claimed: boolean;
2820
3789
  }
2821
3790
  /**
2822
3791
  * Callback signature for promo-code redemption WITHIN the onboarding
@@ -2834,14 +3803,46 @@ type RedeemPromoInTransactionCallback = (tx: TransactionContext, subscriptionId:
2834
3803
  * app-specific. The platform service calls `invalidateTenant` in the
2835
3804
  * EntitlementService after a successful adapter call.
2836
3805
  */
3806
+ /** What `cancelSubscription` is told to write. Named so both adapters spell
3807
+ * the same shape once rather than each restating it. */
3808
+ interface CancelSubscriptionInput {
3809
+ canceledAt: Date;
3810
+ effectiveAt: Date;
3811
+ terminateNow: boolean;
3812
+ minimumTermUntil?: Date;
3813
+ }
3814
+ /** What `cancelSubscription` answers with. */
3815
+ interface CancelSubscriptionResult {
3816
+ canceledAt: Date | null;
3817
+ canceledEffectiveAt: Date | null;
3818
+ status: string;
3819
+ /**
3820
+ * True when a cancellation was already recorded and this call changed
3821
+ * nothing — the stored dates are returned instead.
3822
+ *
3823
+ * The caller checks first, but a check and a write are two moments, and two
3824
+ * requests can pass the check before either writes. Straddling a notice
3825
+ * deadline that costs a billing cycle: the first declaration lands on time,
3826
+ * the second recomputes against a later `now`, and an unconditional write
3827
+ * replaces the first answer with one a period further out. An
3828
+ * implementation therefore claims the row only while both cancellation
3829
+ * fields are still empty, and answers `true` here when the claim finds
3830
+ * nothing to claim.
3831
+ */
3832
+ alreadyCanceled: boolean;
3833
+ }
2837
3834
  interface TenantSubscriptionWritePort {
2838
3835
  /** Immediate change: set plan + cycle, clear pending fields, optionally reset the period. */
2839
3836
  changePlanImmediate(tenantId: string, input: ImmediatePlanChangeInput): Promise<{
2840
3837
  plan: string;
2841
3838
  billingCycle: string;
3839
+ /** False when the row's cancellation moved since the caller read it. */
3840
+ claimed: boolean;
2842
3841
  }>;
2843
3842
  /** Change at period end: set pending fields. */
2844
- schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<void>;
3843
+ schedulePlanChange(tenantId: string, input: ScheduledPlanChangeInput): Promise<{
3844
+ claimed: boolean;
3845
+ }>;
2845
3846
  /**
2846
3847
  * Marks the pending PlanVersion as accepted. Idempotent — a duplicate
2847
3848
  * accept is a no-op. Returns `alreadyAccepted: true` if the status was
@@ -2854,13 +3855,30 @@ interface TenantSubscriptionWritePort {
2854
3855
  alreadyAccepted: boolean;
2855
3856
  }>;
2856
3857
  /**
2857
- * Cancel the subscription. `immediate=true` → status CANCELED from now;
2858
- * `false` → canceledAt = currentPeriodEnd, status is preserved.
3858
+ * Record a cancellation. The dates are decided above this port.
3859
+ *
3860
+ * `canceledAt` is when the customer said it; `effectiveAt` is when it
3861
+ * lands. They differ for every ordinary cancellation, because a
3862
+ * subscription cancelled inside its term keeps running, keeps being billed
3863
+ * and keeps its entitlements until the term ends. An adapter that computed
3864
+ * the second from the first — which this one did, as
3865
+ * `immediate ? now : currentPeriodEnd` — was deciding a commercial
3866
+ * question in a persistence layer, and could not see the minimum term or
3867
+ * the notice period at all.
3868
+ *
3869
+ * `terminateNow` flips the status immediately, and is set when the
3870
+ * cancellation is already effective: an operator ending a contract, or the
3871
+ * rules finding nothing left to run — no period, no term, as on a trial.
3872
+ * It is never a client's request. A tenant may always declare a
3873
+ * cancellation and may never shorten the term they are in; what decides
3874
+ * this flag is the date the rules returned, not the date they asked for.
3875
+ *
3876
+ * `minimumTermUntil` extends the stored commitment, and is set only when
3877
+ * the cancellation itself extends it: a declaration made after the notice
3878
+ * deadline buys the following period. Left unset the stored term end is
3879
+ * unchanged, which is the ordinary case.
2859
3880
  */
2860
- cancelSubscription(tenantId: string, immediate: boolean, now: Date): Promise<{
2861
- canceledAt: Date | null;
2862
- status: string;
2863
- }>;
3881
+ cancelSubscription(tenantId: string, input: CancelSubscriptionInput): Promise<CancelSubscriptionResult>;
2864
3882
  /**
2865
3883
  * Atomic onboarding creation: sets plan + cycle + period window
2866
3884
  * AND optionally calls a promo-redeem callback — all in a
@@ -2883,6 +3901,8 @@ interface PlanVersionRepository {
2883
3901
  *
2884
3902
  * Note: ignores `validFrom`/`validUntil`. For time-aware
2885
3903
  * resolution (onboarding, plan fallback for TRIAL) use `findActive`.
3904
+ *
3905
+ * A plan key no plan has finds `null`, not an error.
2886
3906
  */
2887
3907
  findLatestLive(planId: string, tx?: TransactionContext): Promise<PlanVersionRecord | null>;
2888
3908
  /**
@@ -2895,6 +3915,8 @@ interface PlanVersionRepository {
2895
3915
  * return the highest `validFrom`, explicitly ordering null start dates
2896
3916
  * last as a legacy fallback. Adapters without validity columns may omit
2897
3917
  * the method (consumers fall back to `findLatestLive`).
3918
+ *
3919
+ * A plan key no plan has finds `null`, not an error.
2898
3920
  */
2899
3921
  findActive?(planId: string, asOf?: Date, tx?: TransactionContext): Promise<PlanVersionRecord | null>;
2900
3922
  }
@@ -3100,11 +4122,18 @@ interface ManifestAccessPort {
3100
4122
  * Adapter for the RLS bypass context. Platform code calls `runWithBypass`,
3101
4123
  * the consumer implementation triggers the Postgres session variable
3102
4124
  * (`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.).
4125
+ * Django/other stacks. The execution context lives for the call it wraps
4126
+ * (AsyncLocalStorage / `contextvars` etc.).
3105
4127
  *
3106
4128
  * SuperAdmin operations are platform-wide without tenant scope — without
3107
4129
  * bypass, all RLS-protected reads would come back empty.
4130
+ *
4131
+ * **Not only inside a request.** The platform's boot checks read platform-wide
4132
+ * before anything is served: an implementation that reaches for a
4133
+ * request-scoped connection, or that asserts a request context is open, fails
4134
+ * at start rather than where it is called. Wrap the call the way it is given —
4135
+ * `AsyncLocalStorage.run` is the shape both shipped adapters use, and it is as
4136
+ * good at boot as it is in a request.
3108
4137
  */
3109
4138
  interface RlsBypassPort {
3110
4139
  runWithBypass<T>(fn: () => Promise<T>): Promise<T>;
@@ -3112,7 +4141,6 @@ interface RlsBypassPort {
3112
4141
 
3113
4142
  /** Filter for `PlanRepository.list()`. */
3114
4143
  interface PlanListFilter {
3115
- projectKey: string;
3116
4144
  /** Exclude soft-deleted plans — default `true`. */
3117
4145
  excludeDeleted?: boolean;
3118
4146
  /**
@@ -3146,7 +4174,7 @@ interface PlanListFilter {
3146
4174
  interface PlanRepository {
3147
4175
  list(filter: PlanListFilter): Promise<PlanRow[]>;
3148
4176
  findById(planId: string): Promise<PlanRow | null>;
3149
- findByKey(projectKey: string, planKey: string): Promise<PlanRow | null>;
4177
+ findByKey(planKey: string): Promise<PlanRow | null>;
3150
4178
  create(data: CreatePlanData): Promise<PlanRow>;
3151
4179
  update(planId: string, data: UpdatePlanData): Promise<PlanRow>;
3152
4180
  /** Sets `deletedAt` to NOW(); soft-deleted plans are filtered from `list` by default. */
@@ -3247,10 +4275,24 @@ interface PlanRepository {
3247
4275
  }
3248
4276
  /** Filter for `BundleRepository.list()`. */
3249
4277
  interface BundleListFilter {
3250
- projectKey: string;
3251
4278
  /** Exclude soft-deleted bundles — default `true`. */
3252
4279
  excludeDeleted?: boolean;
3253
4280
  }
4281
+ /**
4282
+ * What publishing a bundle draft records.
4283
+ *
4284
+ * Named because it was written out three times — the port, and each adapter's
4285
+ * implementation of it — and a signature restated is a contract restated: the
4286
+ * copies can drift, and nothing but a reader would notice.
4287
+ */
4288
+ interface PublishBundleVersionMeta {
4289
+ publishedByUserId: string | null;
4290
+ publishedChanges: VersionChange[];
4291
+ nonRegressive: boolean;
4292
+ /** Required — validated by the service before the repository call. */
4293
+ validFrom: Date;
4294
+ validUntil: Date | null;
4295
+ }
3254
4296
  /**
3255
4297
  * Adapter for `Bundle` + `BundleVersion` persistence. Consumers implement
3256
4298
  * this against their Prisma tables (`bundles` + `bundle_versions`).
@@ -3269,7 +4311,7 @@ interface BundleListFilter {
3269
4311
  interface BundleRepository {
3270
4312
  list(filter: BundleListFilter): Promise<BundleRow[]>;
3271
4313
  findById(bundleId: string): Promise<BundleRow | null>;
3272
- findByKey(projectKey: string, bundleKey: string): Promise<BundleRow | null>;
4314
+ findByKey(bundleKey: string): Promise<BundleRow | null>;
3273
4315
  create(data: CreateBundleData): Promise<BundleRow>;
3274
4316
  update(bundleId: string, data: UpdateBundleData): Promise<BundleRow>;
3275
4317
  /** Sets `deletedAt` to NOW(); soft-deleted bundles are filtered from `list` by default. */
@@ -3328,14 +4370,7 @@ interface BundleRepository {
3328
4370
  * if the predecessor carries a `validUntil` — the adapter only
3329
4371
  * persists, it does not validate again.
3330
4372
  */
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>;
4373
+ publishDraft(versionId: string, publishMeta: PublishBundleVersionMeta, tx?: TransactionContext): Promise<BundleVersionRow>;
3339
4374
  /**
3340
4375
  * Hard-discards a draft version (`publishedAt === null`) from the DB.
3341
4376
  * Throws if the version was already published — published versions
@@ -3373,7 +4408,6 @@ interface MarketingProjectionRepository {
3373
4408
  }
3374
4409
  /** Upsert input for a capability from the discovery sync. */
3375
4410
  interface UpsertCapabilityEntryData {
3376
- projectKey: string;
3377
4411
  capabilityKey: string;
3378
4412
  label: string;
3379
4413
  description: string | null;
@@ -3390,7 +4424,6 @@ interface UpsertCapabilityEntryData {
3390
4424
  }
3391
4425
  /** Upsert input for a feature from the discovery sync. */
3392
4426
  interface UpsertFeatureEntryData {
3393
- projectKey: string;
3394
4427
  featureKey: string;
3395
4428
  label: string;
3396
4429
  description: string | null;
@@ -3404,7 +4437,6 @@ interface UpsertFeatureEntryData {
3404
4437
  }
3405
4438
  /** Upsert input for a quota from the discovery sync. */
3406
4439
  interface UpsertQuotaEntryData {
3407
- projectKey: string;
3408
4440
  quotaKey: string;
3409
4441
  label: string;
3410
4442
  description: string | null;
@@ -3434,7 +4466,7 @@ interface SetCatalogEntryReviewData {
3434
4466
  * Prisma tables.
3435
4467
  *
3436
4468
  * Binding:
3437
- * - `upsert*` matches on (`projectKey`, `<key>`) and leaves `i18n`,
4469
+ * - `upsert*` matches on `<key>` and leaves `i18n`,
3438
4470
  * `sortOrder`, `createdAt` as well as the approval fields (`approvedAt`/
3439
4471
  * `approvedBy`/`approvedSignature`) **untouched** on an update —
3440
4472
  * only the code-derived fields + the status (resolved by the service)
@@ -3450,7 +4482,7 @@ interface CatalogEntryRepository {
3450
4482
  upsertCapability(data: UpsertCapabilityEntryData): Promise<CapabilityCatalogEntryRow>;
3451
4483
  upsertFeature(data: UpsertFeatureEntryData): Promise<FeatureCatalogEntryRow>;
3452
4484
  upsertQuota(data: UpsertQuotaEntryData): Promise<QuotaCatalogEntryRow>;
3453
- retireMissing(projectKey: string, type: 'capability' | 'feature' | 'quota', presentKeys: string[]): Promise<number>;
4485
+ retireMissing(type: 'capability' | 'feature' | 'quota', presentKeys: string[]): Promise<number>;
3454
4486
  /**
3455
4487
  * Sets or clears the successor pointer of a feature/quota
3456
4488
  * (#39). The sync calls this when a key disappears from the snapshot
@@ -3459,17 +4491,17 @@ interface CatalogEntryRepository {
3459
4491
  * adapters without a `successor_key` column omit the methods, and the sync
3460
4492
  * then skips the pointers with a warn log.
3461
4493
  */
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>;
4494
+ setFeatureSuccessor?(featureKey: string, successorKey: string | null): Promise<FeatureCatalogEntryRow>;
4495
+ setQuotaSuccessor?(quotaKey: string, successorKey: string | null): Promise<QuotaCatalogEntryRow>;
4496
+ findFeature(featureKey: string): Promise<FeatureCatalogEntryRow | null>;
4497
+ findQuota(quotaKey: string): Promise<QuotaCatalogEntryRow | null>;
4498
+ setFeatureReview(featureKey: string, data: SetCatalogEntryReviewData): Promise<FeatureCatalogEntryRow>;
4499
+ setQuotaReview(quotaKey: string, data: SetCatalogEntryReviewData): Promise<QuotaCatalogEntryRow>;
4500
+ setFeatureI18n(featureKey: string, i18n: CatalogEntryI18n): Promise<FeatureCatalogEntryRow>;
4501
+ setQuotaI18n(quotaKey: string, i18n: CatalogEntryI18n): Promise<QuotaCatalogEntryRow>;
3470
4502
  /** 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>;
4503
+ setFeatureBase(featureKey: string, data: UpdateCatalogEntryBaseData): Promise<FeatureCatalogEntryRow>;
4504
+ setQuotaBase(quotaKey: string, data: UpdateCatalogEntryBaseData): Promise<QuotaCatalogEntryRow>;
3473
4505
  }
3474
4506
  /**
3475
4507
  * Adapter for `promotions`. **No versioning** — promotions are edited
@@ -3477,7 +4509,7 @@ interface CatalogEntryRepository {
3477
4509
  * this against their `promotions` Prisma table.
3478
4510
  */
3479
4511
  interface PromotionRepository {
3480
- list(filter: PromotionFilter): Promise<PromotionRow[]>;
4512
+ list(): Promise<PromotionRow[]>;
3481
4513
  findById(id: string): Promise<PromotionRow | null>;
3482
4514
  create(data: CreatePromotionData): Promise<PromotionRow>;
3483
4515
  update(id: string, data: UpdatePromotionData): Promise<PromotionRow>;
@@ -3485,27 +4517,260 @@ interface PromotionRepository {
3485
4517
  delete(id: string): Promise<void>;
3486
4518
  }
3487
4519
  /**
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.
4520
+ * Adapter for `marketing_settings` — at most one row, which a `CHECK` on the
4521
+ * canonical schema holds rather than convention. `get` returns `null` as long
4522
+ * as the SuperAdmin has saved nothing (then the full `availableLocales` pool
4523
+ * counts as active). `upsert` creates the row or replaces it.
4524
+ */
4525
+ interface MarketingSettingsRepository {
4526
+ get(): Promise<MarketingSettingsRow | null>;
4527
+ upsert(data: UpdateMarketingSettingsData): Promise<MarketingSettingsRow>;
4528
+ }
4529
+
4530
+ /**
4531
+ * Adapter for `checkout_offers`. The offer is an immutable bundle snapshot:
4532
+ * `create` creates it, `update` only allows customization while
4533
+ * `status = 'open'`, `consume` freezes it.
4534
+ */
4535
+ interface CheckoutOfferRepository {
4536
+ list(filter: CheckoutOfferFilter): Promise<CheckoutOfferRow[]>;
4537
+ findById(id: string): Promise<CheckoutOfferRow | null>;
4538
+ create(data: CreateCheckoutOfferData): Promise<CheckoutOfferRow>;
4539
+ update(id: string, data: UpdateCheckoutOfferData): Promise<CheckoutOfferRow>;
4540
+ /**
4541
+ * Sets `status = 'consumed'` + `consumedAt = NOW()`, and only while the
4542
+ * offer is still `open`: the write carries that condition, so of two
4543
+ * callers consuming at once one succeeds and the other is refused with
4544
+ * `CHECKOUT_OFFER_ALREADY_CONSUMED` (or `CHECKOUT_OFFER_EXPIRED`). A check
4545
+ * before the write cannot decide it, because the status can change in
4546
+ * between.
4547
+ *
4548
+ * With `tx`, the write runs on that transaction, so it is undone with
4549
+ * everything else the transaction wrote. `CheckoutOfferService.conclude`
4550
+ * depends on that; the persistence contract holds both.
4551
+ */
4552
+ consume(id: string, tx?: TransactionContext): Promise<CheckoutOfferRow>;
4553
+ }
4554
+
4555
+ /** `ACTIVE` is the one in use; `REPLACED` is one a later payment method took over from. */
4556
+ type SubscriberPaymentMethodStatus = 'ACTIVE' | 'REPLACED';
4557
+ interface SubscriberPaymentMethodRecord extends ConfirmedPaymentMethod {
4558
+ id: string;
4559
+ subscriberId: string;
4560
+ /** The account in `config/saas.yaml#payments.accounts` the references belong to. */
4561
+ gatewayAccount: string;
4562
+ /** The provider of that account when the payment method was confirmed. */
4563
+ provider: string;
4564
+ status: SubscriberPaymentMethodStatus;
4565
+ /** When the gateway confirmed the payment method. */
4566
+ confirmedAt: Date;
4567
+ /** When a later payment method took over; `null` while this one is in use. */
4568
+ replacedAt: Date | null;
4569
+ createdAt: Date;
4570
+ }
4571
+ /** What a repository records for a payment method the gateway confirmed. */
4572
+ interface RecordSubscriberPaymentMethodData extends ConfirmedPaymentMethod {
4573
+ subscriberId: string;
4574
+ gatewayAccount: string;
4575
+ provider: string;
4576
+ confirmedAt: Date;
4577
+ }
4578
+ /**
4579
+ * - `activated`: it is the subscriber's payment method now, and the one it
4580
+ * replaced, if any, is `REPLACED`.
4581
+ * - `already-recorded`: the account's reference was recorded before; nothing
4582
+ * was written, and `method` is the row that holds it.
4583
+ * - `superseded`: the subscriber's payment method in use was confirmed after
4584
+ * this one, so this one is recorded as already replaced — confirmations can
4585
+ * arrive in another order than the forms were filled in.
4586
+ */
4587
+ type RecordSubscriberPaymentMethodOutcome = 'activated' | 'already-recorded' | 'superseded';
4588
+ /**
4589
+ * A change of payment method a tenant started: the gateway session it opened
4590
+ * for the subscriber. A confirmation is recorded only against the setup it
4591
+ * belongs to, so a callback that names another subscriber than the session was
4592
+ * opened for changes nobody's payment method.
4593
+ */
4594
+ interface SubscriberPaymentMethodSetupData {
4595
+ subscriberId: string;
4596
+ gatewayAccount: string;
4597
+ /** The gateway's session, unique within the account. */
4598
+ sessionRef: string;
4599
+ /** The customer the gateway keeps the payment method under. */
4600
+ customerRef: string;
4601
+ startedAt: Date;
4602
+ }
4603
+ /** Which setup a confirmation claims to complete. */
4604
+ interface SubscriberPaymentMethodSetupMatch {
4605
+ gatewayAccount: string;
4606
+ sessionRef: string;
4607
+ subscriberId: string;
4608
+ }
4609
+ interface RecordSubscriberPaymentMethodResult {
4610
+ method: SubscriberPaymentMethodRecord;
4611
+ outcome: RecordSubscriberPaymentMethodOutcome;
4612
+ }
4613
+
4614
+ /** A gateway event, as the log records it. */
4615
+ interface PaymentEventClaim {
4616
+ /** The account the event came from; an event identifier is unique only within it. */
4617
+ gatewayAccount: string;
4618
+ eventId: string;
4619
+ provider: string;
4620
+ /** The gateway session the event is about, where it is about one. */
4621
+ sessionId: string | null;
4622
+ kind: PaymentGatewayEvent['kind'];
4623
+ /** What the event said, without anything that names or reaches a person. */
4624
+ summary: Record<string, unknown>;
4625
+ }
4626
+ /**
4627
+ * The events a gateway sent, each handled once.
4628
+ *
4629
+ * Gateways deliver at least once, so the same event arrives again after a
4630
+ * timeout or a retry. An event is claimed on the transaction that writes what
4631
+ * it changes: when that transaction rolls back, the claim goes with it and the
4632
+ * gateway's retry is handled rather than discarded as a duplicate.
4633
+ */
4634
+ interface PaymentEventLog {
4635
+ /**
4636
+ * Records the event and returns `true`, or returns `false` when this account
4637
+ * already recorded it, writing nothing.
4638
+ *
4639
+ * The duplicate must not raise: on PostgreSQL an error aborts the
4640
+ * transaction it happened on, and the caller's transaction has to stay
4641
+ * usable. An `INSERT … ON CONFLICT DO NOTHING` does both — and when another
4642
+ * transaction holds the same claim uncommitted, it waits for that one and
4643
+ * answers by its outcome.
4644
+ *
4645
+ * A confirmation of a session this account already confirmed is a duplicate
4646
+ * as well, whatever its `eventId`: one session is set up once, and a gateway
4647
+ * may report it through more than one event. `sql/constraints.postgres.sql`
4648
+ * holds that as a second unique index, and the untargeted `DO NOTHING`
4649
+ * answers for it too.
4650
+ */
4651
+ claim(claim: PaymentEventClaim, tx: TransactionContext): Promise<boolean>;
4652
+ /**
4653
+ * Takes the session off a claim whose event changed nothing, so the next
4654
+ * event about that session is handled instead of taken for the duplicate it
4655
+ * is not. The claim itself stays: that one event is never handled twice.
4656
+ *
4657
+ * The session is held from the claim onwards, which is what keeps two
4658
+ * confirmations delivered at once from both taking effect; an event that
4659
+ * turned out to have nothing to do gives it back on the same transaction.
4660
+ */
4661
+ releaseSession(gatewayAccount: string, eventId: string, tx: TransactionContext): Promise<void>;
4662
+ }
4663
+ /**
4664
+ * The payment methods of subscribers, as references at the gateway accounts
4665
+ * that confirmed them.
4666
+ *
4667
+ * A subscriber has at most one `ACTIVE` payment method; the database holds
4668
+ * that, so two confirmations recorded at once for one subscriber end with one.
3491
4669
  */
3492
- interface MarketingSettingsRepository {
3493
- get(projectKey: string): Promise<MarketingSettingsRow | null>;
3494
- upsert(projectKey: string, data: UpdateMarketingSettingsData): Promise<MarketingSettingsRow>;
4670
+ interface SubscriberPaymentMethodRepository {
4671
+ /**
4672
+ * Records a confirmed payment method for its subscriber, under a lock on the
4673
+ * subscriber so two confirmations take turns. See
4674
+ * `RecordSubscriberPaymentMethodOutcome` for the three outcomes.
4675
+ */
4676
+ recordConfirmed(data: RecordSubscriberPaymentMethodData, tx?: TransactionContext): Promise<RecordSubscriberPaymentMethodResult>;
4677
+ /** The subscriber's payment method in use, or `null` when it has none. */
4678
+ findActive(subscriberId: string, tx?: TransactionContext): Promise<SubscriberPaymentMethodRecord | null>;
4679
+ /**
4680
+ * The payment method an account's reference names, whatever its status.
4681
+ *
4682
+ * The one read that reaches a payment method a newer one replaced, which is
4683
+ * how `@saasicat/persistence-testing` verifies that an implementation keeps
4684
+ * the history rather than overwriting the row (`SC-PRIC-030`).
4685
+ */
4686
+ findByReference(gatewayAccount: string, paymentMethodRef: string, tx?: TransactionContext): Promise<SubscriberPaymentMethodRecord | null>;
4687
+ /**
4688
+ * Every account that holds a payment method in use, each once.
4689
+ *
4690
+ * Platform-wide: anchored by no subscriber and no tenant, and a start makes
4691
+ * it before anything is served, to refuse a configuration that no longer
4692
+ * names an account somebody's payment method is held at. An implementation
4693
+ * on a tenant-scoped client must read RLS-exempt — the platform wraps the
4694
+ * call in `RlsBypassPort`, and one that answers with the caller's tenant
4695
+ * scope instead returns an empty list at a boot, where there is no tenant,
4696
+ * so the check that exists to refuse passes. The persistence contract runs
4697
+ * with no policy forced, so it cannot catch that for you.
4698
+ */
4699
+ accountsInUse(): Promise<string[]>;
4700
+ /**
4701
+ * Records a change of payment method a tenant started, open until its
4702
+ * confirmation completes it.
4703
+ *
4704
+ * One session is one setup: the account and the session are unique together,
4705
+ * and a second setup for a session already recorded raises. A gateway hands
4706
+ * out a session per start, so an adapter that returns one twice is the
4707
+ * defect this refuses to write over.
4708
+ */
4709
+ recordSetup(data: SubscriberPaymentMethodSetupData, tx?: TransactionContext): Promise<void>;
4710
+ /**
4711
+ * Completes the open setup the account, the session and the subscriber all
4712
+ * name, and returns `true` — or returns `false`, writing nothing, when no
4713
+ * open setup matches all three: none was started, it was started for another
4714
+ * subscriber, or it is complete already. A single conditional write, so two
4715
+ * confirmations for one setup complete it once.
4716
+ */
4717
+ completeSetup(match: SubscriberPaymentMethodSetupMatch, completedAt: Date, tx?: TransactionContext): Promise<boolean>;
3495
4718
  }
3496
4719
 
4720
+ /** Which changes to list. */
4721
+ interface SettingsChangeFilter {
4722
+ /** Only changes an operator has, or has not, acknowledged. Omitted: both. */
4723
+ acknowledged?: boolean;
4724
+ /** The most recently recorded ones. Omitted: every matching change. */
4725
+ limit?: number;
4726
+ }
3497
4727
  /**
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>;
4728
+ * Stores what the installation applied and what changed between two boots.
4729
+ *
4730
+ * `applied_settings` holds one row for the installation; `settings_changes`
4731
+ * holds one row per boot that found the fingerprint moved. An adapter
4732
+ * translates: it does not decide what a change is, and it does not read the
4733
+ * row back into anything that runs.
4734
+ *
4735
+ * Both writes are guarded on the fingerprint the caller read. Several replicas
4736
+ * of one installation start together after one edit of the file, each reads
4737
+ * the same record and each finds the same difference; the guard is what makes
4738
+ * one of them the boot that recorded it and the others boots that found it
4739
+ * recorded. Without it every replica would write the change and mail the
4740
+ * addresses, once per replica.
4741
+ */
4742
+ interface AppliedSettingsPort {
4743
+ /** The record, or null before the first boot that could write one. */
4744
+ readApplied(): Promise<AppliedSettingsRecord | null>;
4745
+ /**
4746
+ * Replaces the installation's record — there is only ever the one row —
4747
+ * provided the stored record still carries `expectedFingerprint`: the
4748
+ * fingerprint the caller read, or `null` where it read no record. Returns
4749
+ * whether it did. `false` means the record moved between the caller's read
4750
+ * and this write, and nothing was written: another boot got there first.
4751
+ */
4752
+ writeApplied(record: AppliedSettingsRecord, expectedFingerprint: string | null): Promise<boolean>;
4753
+ /**
4754
+ * Appends a change a boot noticed and replaces the record it supersedes, in
4755
+ * one step: both land, or neither does. Guarded like `writeApplied`, on the
4756
+ * fingerprint of the record the change was noticed against. Returns the
4757
+ * change as stored — the id is the adapter's to assign — or `null` where
4758
+ * the record had already moved on: another boot noticed first, and the
4759
+ * change is that boot's to report.
4760
+ */
4761
+ recordChange(change: NewSettingsChange, record: AppliedSettingsRecord, expectedFingerprint: string): Promise<SettingsChangeRecord | null>;
4762
+ /**
4763
+ * Changes, the most recently recorded first: the order the record went
4764
+ * through them, which the database numbers at each write — not the order
4765
+ * of the moments they carry, which are the recording starts' clocks.
4766
+ */
4767
+ listChanges(filter?: SettingsChangeFilter): Promise<SettingsChangeRecord[]>;
4768
+ /**
4769
+ * Marks a change as seen. Returns the updated record, or null where no
4770
+ * change has that id. A change already acknowledged keeps its first
4771
+ * acknowledgement — repeating the action changes nothing.
4772
+ */
4773
+ acknowledgeChange(id: string, acknowledgedBy: string, acknowledgedAt: Date): Promise<SettingsChangeRecord | null>;
3509
4774
  }
3510
4775
 
3511
4776
  /** Class reference usable as a DI token (e.g. the consumer's `PrismaService`). */
@@ -3565,12 +4830,20 @@ interface SaaSiCatPersistenceCore {
3565
4830
  auditQuery?: PersistenceProvider<AuditQueryPort>;
3566
4831
  /** Aggregation for the admin stats dashboard. */
3567
4832
  auditStats?: PersistenceProvider<AuditStatsPort>;
4833
+ /**
4834
+ * The record of the applied configuration (`SettingsModule`). Optional so
4835
+ * an adapter written before it existed keeps working; without it the
4836
+ * platform says once at boot that it is not recording.
4837
+ */
4838
+ appliedSettings?: PersistenceProvider<AppliedSettingsPort>;
3568
4839
  }
3569
4840
  /** Repositories for the entitlement/contract loop (`EntitlementModule`). */
3570
4841
  interface SaaSiCatPersistenceEntitlement {
3571
4842
  subscriptionRepository: PersistenceProvider<SubscriptionRepository>;
3572
4843
  planVersionRepository: PersistenceProvider<PlanVersionRepository>;
3573
4844
  subscriptionContractRepository?: PersistenceProvider<SubscriptionContractRepository>;
4845
+ /** The parties contracts are concluded with; required wherever contracts are written. */
4846
+ subscriberRepository?: PersistenceProvider<SubscriberRepository>;
3574
4847
  subscriptionBundleRepository?: PersistenceProvider<SubscriptionBundleRepository>;
3575
4848
  bundleRepository?: PersistenceProvider<BundleRepository>;
3576
4849
  }
@@ -3597,6 +4870,15 @@ interface SaaSiCatPersistenceTenantBilling {
3597
4870
  subscriptionWritePort: PersistenceProvider<TenantSubscriptionWritePort>;
3598
4871
  usageSnapshotPort?: PersistenceProvider<UsageSnapshotPort>;
3599
4872
  }
4873
+ /**
4874
+ * The record of payment gateway callbacks and the payment methods they confirm
4875
+ * (`payments` in `SaaSiCatModule.forRoot`). Both are written on the transaction
4876
+ * a callback is handled on, so they come from the adapter that runs it.
4877
+ */
4878
+ interface SaaSiCatPersistencePayments {
4879
+ paymentEventLog: PersistenceProvider<PaymentEventLog>;
4880
+ subscriberPaymentMethodRepository: PersistenceProvider<SubscriberPaymentMethodRepository>;
4881
+ }
3600
4882
  /** Read/write backing for the standard SuperAdmin resource pages. */
3601
4883
  interface SaaSiCatPersistenceAdminResources {
3602
4884
  resources: PersistenceProvider<AdminResourcesPort>;
@@ -3631,6 +4913,8 @@ interface SaaSiCatPersistenceAdapter {
3631
4913
  /** Tenant, user, audit and subscription resources for the SuperAdmin UI. */
3632
4914
  adminResources?: SaaSiCatPersistenceAdminResources;
3633
4915
  promo?: SaaSiCatPersistencePromo;
4916
+ /** Payment gateway callbacks and subscriber payment methods. */
4917
+ payments?: SaaSiCatPersistencePayments;
3634
4918
  /** DB hydration of the plan catalog at boot (`PlanCatalogModule`). */
3635
4919
  planCatalogReadSink?: PersistenceProvider<PlanCatalogReadSink>;
3636
4920
  /** One-shot `saas.yaml → DB` import. */
@@ -3703,6 +4987,8 @@ declare const CATALOG_ERROR_CODES: {
3703
4987
  readonly BUNDLE_VERSION_SUPERSEDED: "BUNDLE_VERSION_SUPERSEDED";
3704
4988
  readonly BUNDLE_VERSION_REGRESSION: "BUNDLE_VERSION_REGRESSION";
3705
4989
  readonly BUNDLE_VERSION_ZERO_PRICE: "BUNDLE_VERSION_ZERO_PRICE";
4990
+ readonly BUNDLE_VERSION_NO_PRICE: "BUNDLE_VERSION_NO_PRICE";
4991
+ readonly BUNDLE_VERSION_NOT_PRICED_FOR_PLAN: "BUNDLE_VERSION_NOT_PRICED_FOR_PLAN";
3706
4992
  readonly BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED: "BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED";
3707
4993
  readonly BUNDLE_VERSION_VALID_FROM_REQUIRED: "BUNDLE_VERSION_VALID_FROM_REQUIRED";
3708
4994
  readonly BUNDLE_VERSION_VALID_FROM_INVALID: "BUNDLE_VERSION_VALID_FROM_INVALID";
@@ -3730,6 +5016,10 @@ declare const CATALOG_ERROR_CODES: {
3730
5016
  readonly QUOTA_NOT_IN_DISCOVERY_SNAPSHOT: "QUOTA_NOT_IN_DISCOVERY_SNAPSHOT";
3731
5017
  readonly DISCOVERY_STATUS_TRANSITION_INVALID: "DISCOVERY_STATUS_TRANSITION_INVALID";
3732
5018
  readonly DISCOVERY_NOT_INITIALIZED: "DISCOVERY_NOT_INITIALIZED";
5019
+ /** The uploaded document is not a plan catalog — unparseable, or not an object. */
5020
+ readonly PLAN_CATALOG_UNREADABLE: "PLAN_CATALOG_UNREADABLE";
5021
+ /** It parsed, and then failed the schema or a cross-field rule. */
5022
+ readonly PLAN_CATALOG_INVALID: "PLAN_CATALOG_INVALID";
3733
5023
  };
3734
5024
  type CatalogErrorCode = (typeof CATALOG_ERROR_CODES)[keyof typeof CATALOG_ERROR_CODES];
3735
5025
  /** Bundle bookings on a tenant subscription. */
@@ -3743,6 +5033,8 @@ declare const BILLING_ERROR_CODES: {
3743
5033
  readonly BUNDLE_ALREADY_SUBSCRIBED: "BUNDLE_ALREADY_SUBSCRIBED";
3744
5034
  readonly BUNDLE_INCOMPATIBLE_WITH_PLAN: "BUNDLE_INCOMPATIBLE_WITH_PLAN";
3745
5035
  readonly BUNDLE_NOT_SELF_SERVICE: "BUNDLE_NOT_SELF_SERVICE";
5036
+ readonly BUNDLE_CYCLE_EXCEEDS_PLAN: "BUNDLE_CYCLE_EXCEEDS_PLAN";
5037
+ readonly BUNDLE_NOT_PRICED_FOR_THIS_PLAN: "BUNDLE_NOT_PRICED_FOR_THIS_PLAN";
3746
5038
  readonly SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED: "SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED";
3747
5039
  readonly SUBSCRIPTION_BUNDLE_NOT_CANCELLED: "SUBSCRIPTION_BUNDLE_NOT_CANCELLED";
3748
5040
  readonly SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE: "SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE";
@@ -3756,8 +5048,79 @@ declare const BILLING_ERROR_CODES: {
3756
5048
  readonly PLAN_NOT_IN_CATALOG: "PLAN_NOT_IN_CATALOG";
3757
5049
  /** Plan exists but cannot be booked via self-service. */
3758
5050
  readonly PLAN_NOT_SELF_SERVICE: "PLAN_NOT_SELF_SERVICE";
5051
+ /**
5052
+ * Plan carries no price for the requested billing cycle, so it is not sold
5053
+ * in it: a plan without a yearly price is a monthly plan.
5054
+ */
5055
+ readonly PLAN_NOT_SOLD_IN_CYCLE: "PLAN_NOT_SOLD_IN_CYCLE";
3759
5056
  /** Plan change refused. Carries `blockers[]` with their own codes. */
3760
5057
  readonly PLAN_CHANGE_BLOCKED: "PLAN_CHANGE_BLOCKED";
5058
+ /**
5059
+ * The subscription moved between the read a request was decided on and the
5060
+ * write it attempted, so nothing was written. The caller reloads and asks
5061
+ * again.
5062
+ */
5063
+ readonly SUBSCRIPTION_CHANGED: "SUBSCRIPTION_CHANGED";
5064
+ /**
5065
+ * The tenant has no subscription to act on.
5066
+ *
5067
+ * `SUBSCRIPTION_NOT_FOUND` states the same fact on the read routes. Both
5068
+ * are already on the wire and a code is renamed only deliberately, so both
5069
+ * are named here rather than one being dropped behind a consumer's back.
5070
+ */
5071
+ readonly NO_SUBSCRIPTION: "NO_SUBSCRIPTION";
5072
+ /**
5073
+ * The cancellation date the reader was shown is no longer the one the rules
5074
+ * return, so the confirmation is refused rather than silently applied.
5075
+ * Carries the recomputed dates, so the page can re-ask instead of guessing.
5076
+ */
5077
+ readonly CANCELLATION_TERMS_CHANGED: "CANCELLATION_TERMS_CHANGED";
5078
+ /** The subscription has ended; its plan can no longer be changed. */
5079
+ readonly SUBSCRIPTION_ENDED: "SUBSCRIPTION_ENDED";
5080
+ /** An active special contract blocks self-service plan changes. */
5081
+ readonly PLAN_LOCKED: "PLAN_LOCKED";
5082
+ /** Current usage of one quota exceeds what the target plan allows. */
5083
+ readonly QUOTA_OVER_TARGET: "QUOTA_OVER_TARGET";
5084
+ /** The change drops features the tenant has today. */
5085
+ readonly FEATURE_LOST: "FEATURE_LOST";
5086
+ readonly FEATURES_LOST: "FEATURES_LOST";
5087
+ /** Target plan and cycle already match what is in place. */
5088
+ readonly NO_CHANGE: "NO_CHANGE";
5089
+ /** A shorter cycle cannot start inside the term already running. */
5090
+ readonly CYCLE_SHORTENS_AT_TERM_END: "CYCLE_SHORTENS_AT_TERM_END";
5091
+ /** A cancelled subscription cannot change its billing cycle. */
5092
+ readonly CANCELLATION_LOCKS_THE_CYCLE: "CANCELLATION_LOCKS_THE_CYCLE";
5093
+ /**
5094
+ * A bundle the tenant already holds runs past the cycle they are moving to.
5095
+ *
5096
+ * Its own code rather than `BUNDLE_CYCLE_EXCEEDS_PLAN`, which states the
5097
+ * same rule about a booking that has not been made yet. The two need
5098
+ * different sentences: this one can name the day the obstacle lifts and
5099
+ * tell the reader to cancel the booking, and that advice is wrong for
5100
+ * someone who is only about to book. One template cannot serve both.
5101
+ */
5102
+ readonly BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE: "BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE";
5103
+ /**
5104
+ * Features of the previewed bundle are already covered by the plan or by
5105
+ * another booked bundle. A warning rather than a blocker: paying twice is
5106
+ * the customer's decision, and the preview only has to say so first.
5107
+ */
5108
+ readonly REDUNDANT_FEATURES: "REDUNDANT_FEATURES";
5109
+ /**
5110
+ * A booking's minimum term outlasts the period being cancelled, so the
5111
+ * cancellation takes effect at the end of the term, not of the period.
5112
+ */
5113
+ readonly MINIMUM_TERM_BINDS: "MINIMUM_TERM_BINDS";
5114
+ /**
5115
+ * The previewed bundle requires features that neither the plan nor an
5116
+ * active booking provides.
5117
+ *
5118
+ * The same string is a `StrictModeWarningCode` in `bundle.types.ts`, where
5119
+ * it names the catalogue-authoring reading of the rule and travels with its
5120
+ * own message. This declaration is the booking preview's blocker, which a
5121
+ * tenant reads and therefore needs a shipped text for.
5122
+ */
5123
+ readonly BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED";
3761
5124
  readonly NO_PENDING_PLAN_VERSION: "NO_PENDING_PLAN_VERSION";
3762
5125
  readonly ONBOARDING_CREATE_FAILED: "ONBOARDING_CREATE_FAILED";
3763
5126
  readonly BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS: "BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS";
@@ -3778,20 +5141,59 @@ declare const CONTRACT_ERROR_CODES: {
3778
5141
  readonly CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED";
3779
5142
  readonly CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE: "CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE";
3780
5143
  readonly CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED: "CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED";
5144
+ readonly CHECKOUT_OFFER_PLAN_NOT_OFFERED: "CHECKOUT_OFFER_PLAN_NOT_OFFERED";
5145
+ readonly CHECKOUT_OFFER_BUNDLE_NOT_OFFERED: "CHECKOUT_OFFER_BUNDLE_NOT_OFFERED";
5146
+ readonly CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED: "CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED";
5147
+ readonly CHECKOUT_OFFER_PRICE_NOT_CURRENT: "CHECKOUT_OFFER_PRICE_NOT_CURRENT";
3781
5148
  readonly SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED: "SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED";
3782
5149
  readonly SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED: "SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED";
3783
5150
  readonly SUBSCRIPTION_CONTRACT_INVALID_DATE: "SUBSCRIPTION_CONTRACT_INVALID_DATE";
3784
5151
  readonly SUBSCRIPTION_CONTRACT_INVALID_WINDOW: "SUBSCRIPTION_CONTRACT_INVALID_WINDOW";
5152
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH";
5153
+ readonly SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT: "SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT";
5154
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH";
3785
5155
  readonly SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START";
3786
5156
  readonly CHECKOUT_OFFER_NOT_FOUND: "CHECKOUT_OFFER_NOT_FOUND";
3787
5157
  readonly CHECKOUT_OFFER_EXPIRED: "CHECKOUT_OFFER_EXPIRED";
3788
5158
  readonly CHECKOUT_OFFER_ALREADY_CONSUMED: "CHECKOUT_OFFER_ALREADY_CONSUMED";
3789
5159
  readonly CHECKOUT_OFFER_NOT_CONSUMED: "CHECKOUT_OFFER_NOT_CONSUMED";
5160
+ /**
5161
+ * The offer changed between the checks of `conclude` and the transaction
5162
+ * that consumed it, so the contract checked is not the one the offer now
5163
+ * describes. Nothing was written; load the offer and conclude it again.
5164
+ */
5165
+ readonly CHECKOUT_OFFER_CHANGED: "CHECKOUT_OFFER_CHANGED";
3790
5166
  readonly SUBSCRIPTION_CONTRACT_NOT_FOUND: "SUBSCRIPTION_CONTRACT_NOT_FOUND";
3791
5167
  readonly NO_ACTIVE_SUBSCRIPTION_CONTRACT: "NO_ACTIVE_SUBSCRIPTION_CONTRACT";
3792
5168
  readonly SUBSCRIPTION_CONTRACT_ALREADY_CLOSED: "SUBSCRIPTION_CONTRACT_ALREADY_CLOSED";
3793
5169
  };
3794
5170
  type ContractErrorCode = (typeof CONTRACT_ERROR_CODES)[keyof typeof CONTRACT_ERROR_CODES];
5171
+ /** The parties contracts are concluded with (`SubscriberService`), and their absence. */
5172
+ declare const SUBSCRIBER_ERROR_CODES: {
5173
+ /**
5174
+ * A contract, a plan change or a booking for a tenant that has no
5175
+ * subscriber. Nothing is agreed or charged without the party to it.
5176
+ * Carries `tenantId`.
5177
+ */
5178
+ readonly SUBSCRIBER_REQUIRED: "SUBSCRIBER_REQUIRED";
5179
+ /** The tenant already has a live subscriber. Carries `tenantId`. */
5180
+ readonly SUBSCRIBER_ALREADY_EXISTS: "SUBSCRIBER_ALREADY_EXISTS";
5181
+ readonly SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND";
5182
+ readonly SUBSCRIBER_LEGAL_NAME_REQUIRED: "SUBSCRIBER_LEGAL_NAME_REQUIRED";
5183
+ /**
5184
+ * A detail that has a form — the country, the invoice email — is not in it,
5185
+ * or one sign-up requires — the billing address — is missing. Carries `field`.
5186
+ */
5187
+ readonly SUBSCRIBER_DETAIL_INVALID: "SUBSCRIBER_DETAIL_INVALID";
5188
+ /** A contact change named a field of the legal identity. Carries `field`. */
5189
+ readonly SUBSCRIBER_IDENTITY_NOT_A_CONTACT: "SUBSCRIBER_IDENTITY_NOT_A_CONTACT";
5190
+ readonly SUBSCRIBER_CORRECTION_REASON_REQUIRED: "SUBSCRIBER_CORRECTION_REASON_REQUIRED";
5191
+ readonly SUBSCRIBER_CORRECTION_ACTOR_REQUIRED: "SUBSCRIBER_CORRECTION_ACTOR_REQUIRED";
5192
+ readonly SUBSCRIBER_CORRECTION_CHANGES_NOTHING: "SUBSCRIBER_CORRECTION_CHANGES_NOTHING";
5193
+ /** The operator declared another legal entity: that is a transfer, not an edit. */
5194
+ readonly SUBSCRIBER_TAKEOVER_IS_A_TRANSFER: "SUBSCRIBER_TAKEOVER_IS_A_TRANSFER";
5195
+ };
5196
+ type SubscriberErrorCode = (typeof SUBSCRIBER_ERROR_CODES)[keyof typeof SUBSCRIBER_ERROR_CODES];
3795
5197
  /** Self-service registration funnel (`PendingRegistration`). */
3796
5198
  declare const REGISTRATION_ERROR_CODES: {
3797
5199
  readonly PENDING_REGISTRATION_NOT_FOUND: "PENDING_REGISTRATION_NOT_FOUND";
@@ -3820,6 +5222,12 @@ declare const AUTH_ERROR_CODES: {
3820
5222
  /** Neither `tenantId` nor `userId` could be resolved from the request. */
3821
5223
  readonly TENANT_CONTEXT_MISSING: "TENANT_CONTEXT_MISSING";
3822
5224
  readonly TENANT_ADMIN_REQUIRED: "TENANT_ADMIN_REQUIRED";
5225
+ /**
5226
+ * The tenant's billing area — its payment method, and later its invoices
5227
+ * and account — needs the billing permission, which the application maps
5228
+ * to its roles and the tenant's administrator holds by default.
5229
+ */
5230
+ readonly BILLING_PERMISSION_REQUIRED: "BILLING_PERMISSION_REQUIRED";
3823
5231
  readonly SUPER_ADMIN_REQUIRED: "SUPER_ADMIN_REQUIRED";
3824
5232
  /** TOTP MFA has never been set up for this user. */
3825
5233
  readonly MFA_NOT_SET_UP: "MFA_NOT_SET_UP";
@@ -3850,6 +5258,30 @@ declare const PROMO_ERROR_CODES: {
3850
5258
  readonly PROMO_MAX_REDEMPTIONS_LOWERED: "PROMO_MAX_REDEMPTIONS_LOWERED";
3851
5259
  };
3852
5260
  type PromoErrorCode = (typeof PROMO_ERROR_CODES)[keyof typeof PROMO_ERROR_CODES];
5261
+ /** Payment methods and the gateways that confirm them. */
5262
+ declare const PAYMENT_ERROR_CODES: {
5263
+ /**
5264
+ * No gateway account takes new payment methods:
5265
+ * `config/saas.yaml#payments.newPaymentMethods` names none.
5266
+ */
5267
+ readonly PAYMENTS_NOT_CONFIGURED: "PAYMENTS_NOT_CONFIGURED";
5268
+ /** A callback arrived for an account `config/saas.yaml#payments.accounts` does not name. Carries `account`. */
5269
+ readonly PAYMENT_GATEWAY_ACCOUNT_UNKNOWN: "PAYMENT_GATEWAY_ACCOUNT_UNKNOWN";
5270
+ /** A callback the gateway did not send: its signature does not verify. */
5271
+ readonly PAYMENT_CALLBACK_REJECTED: "PAYMENT_CALLBACK_REJECTED";
5272
+ /**
5273
+ * A success or cancel URL at an origin `config/saas.yaml#payments.returnUrlOrigins`
5274
+ * does not name. Carries `field`.
5275
+ */
5276
+ readonly PAYMENT_RETURN_URL_NOT_ALLOWED: "PAYMENT_RETURN_URL_NOT_ALLOWED";
5277
+ };
5278
+ type PaymentErrorCode = (typeof PAYMENT_ERROR_CODES)[keyof typeof PAYMENT_ERROR_CODES];
5279
+ /** Codes of the settings record (`GET /admin/settings`, the acknowledgement). */
5280
+ declare const SETTINGS_ERROR_CODES: {
5281
+ /** No recorded change has this id, or the installation keeps no record at all. */
5282
+ readonly SETTINGS_CHANGE_NOT_FOUND: "SETTINGS_CHANGE_NOT_FOUND";
5283
+ };
5284
+ type SettingsErrorCode = (typeof SETTINGS_ERROR_CODES)[keyof typeof SETTINGS_ERROR_CODES];
3853
5285
  /**
3854
5286
  * Every exception code the platform emits, in one object.
3855
5287
  *
@@ -3858,6 +5290,22 @@ type PromoErrorCode = (typeof PROMO_ERROR_CODES)[keyof typeof PROMO_ERROR_CODES]
3858
5290
  * removing one may not.
3859
5291
  */
3860
5292
  declare const PLATFORM_ERROR_CODES: {
5293
+ /** No recorded change has this id, or the installation keeps no record at all. */
5294
+ readonly SETTINGS_CHANGE_NOT_FOUND: "SETTINGS_CHANGE_NOT_FOUND";
5295
+ /**
5296
+ * No gateway account takes new payment methods:
5297
+ * `config/saas.yaml#payments.newPaymentMethods` names none.
5298
+ */
5299
+ readonly PAYMENTS_NOT_CONFIGURED: "PAYMENTS_NOT_CONFIGURED";
5300
+ /** A callback arrived for an account `config/saas.yaml#payments.accounts` does not name. Carries `account`. */
5301
+ readonly PAYMENT_GATEWAY_ACCOUNT_UNKNOWN: "PAYMENT_GATEWAY_ACCOUNT_UNKNOWN";
5302
+ /** A callback the gateway did not send: its signature does not verify. */
5303
+ readonly PAYMENT_CALLBACK_REJECTED: "PAYMENT_CALLBACK_REJECTED";
5304
+ /**
5305
+ * A success or cancel URL at an origin `config/saas.yaml#payments.returnUrlOrigins`
5306
+ * does not name. Carries `field`.
5307
+ */
5308
+ readonly PAYMENT_RETURN_URL_NOT_ALLOWED: "PAYMENT_RETURN_URL_NOT_ALLOWED";
3861
5309
  readonly PENDING_REGISTRATION_NOT_FOUND: "PENDING_REGISTRATION_NOT_FOUND";
3862
5310
  readonly PENDING_REGISTRATION_EXPIRED: "PENDING_REGISTRATION_EXPIRED";
3863
5311
  readonly INVALID_REGISTRATION_STATE: "INVALID_REGISTRATION_STATE";
@@ -3873,20 +5321,55 @@ declare const PLATFORM_ERROR_CODES: {
3873
5321
  readonly PLAN_NOT_AVAILABLE: "PLAN_NOT_AVAILABLE";
3874
5322
  readonly PLAN_NOT_SELECTED: "PLAN_NOT_SELECTED";
3875
5323
  readonly MODEL_NOT_AVAILABLE: "MODEL_NOT_AVAILABLE";
5324
+ /**
5325
+ * A contract, a plan change or a booking for a tenant that has no
5326
+ * subscriber. Nothing is agreed or charged without the party to it.
5327
+ * Carries `tenantId`.
5328
+ */
5329
+ readonly SUBSCRIBER_REQUIRED: "SUBSCRIBER_REQUIRED";
5330
+ /** The tenant already has a live subscriber. Carries `tenantId`. */
5331
+ readonly SUBSCRIBER_ALREADY_EXISTS: "SUBSCRIBER_ALREADY_EXISTS";
5332
+ readonly SUBSCRIBER_NOT_FOUND: "SUBSCRIBER_NOT_FOUND";
5333
+ readonly SUBSCRIBER_LEGAL_NAME_REQUIRED: "SUBSCRIBER_LEGAL_NAME_REQUIRED";
5334
+ /**
5335
+ * A detail that has a form — the country, the invoice email — is not in it,
5336
+ * or one sign-up requires — the billing address — is missing. Carries `field`.
5337
+ */
5338
+ readonly SUBSCRIBER_DETAIL_INVALID: "SUBSCRIBER_DETAIL_INVALID";
5339
+ /** A contact change named a field of the legal identity. Carries `field`. */
5340
+ readonly SUBSCRIBER_IDENTITY_NOT_A_CONTACT: "SUBSCRIBER_IDENTITY_NOT_A_CONTACT";
5341
+ readonly SUBSCRIBER_CORRECTION_REASON_REQUIRED: "SUBSCRIBER_CORRECTION_REASON_REQUIRED";
5342
+ readonly SUBSCRIBER_CORRECTION_ACTOR_REQUIRED: "SUBSCRIBER_CORRECTION_ACTOR_REQUIRED";
5343
+ readonly SUBSCRIBER_CORRECTION_CHANGES_NOTHING: "SUBSCRIBER_CORRECTION_CHANGES_NOTHING";
5344
+ /** The operator declared another legal entity: that is a transfer, not an edit. */
5345
+ readonly SUBSCRIBER_TAKEOVER_IS_A_TRANSFER: "SUBSCRIBER_TAKEOVER_IS_A_TRANSFER";
3876
5346
  readonly CHECKOUT_OFFER_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_LINE_ITEMS_REQUIRED";
3877
5347
  readonly CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED: "CHECKOUT_OFFER_PLAN_LINE_ITEM_REQUIRED";
3878
5348
  readonly CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED: "CHECKOUT_OFFER_BUNDLE_LINE_ITEMS_REQUIRED";
3879
5349
  readonly CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE: "CHECKOUT_OFFER_BUNDLE_VERSION_NOT_BOOKABLE";
3880
5350
  readonly CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED: "CHECKOUT_OFFER_FEATURE_DEPENDENCY_UNSATISFIED";
5351
+ readonly CHECKOUT_OFFER_PLAN_NOT_OFFERED: "CHECKOUT_OFFER_PLAN_NOT_OFFERED";
5352
+ readonly CHECKOUT_OFFER_BUNDLE_NOT_OFFERED: "CHECKOUT_OFFER_BUNDLE_NOT_OFFERED";
5353
+ readonly CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED: "CHECKOUT_OFFER_PROMO_CODE_NOT_ACCEPTED";
5354
+ readonly CHECKOUT_OFFER_PRICE_NOT_CURRENT: "CHECKOUT_OFFER_PRICE_NOT_CURRENT";
3881
5355
  readonly SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED: "SUBSCRIPTION_CONTRACT_LINE_ITEMS_REQUIRED";
3882
5356
  readonly SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED: "SUBSCRIPTION_CONTRACT_PLAN_LINE_ITEM_REQUIRED";
3883
5357
  readonly SUBSCRIPTION_CONTRACT_INVALID_DATE: "SUBSCRIPTION_CONTRACT_INVALID_DATE";
3884
5358
  readonly SUBSCRIPTION_CONTRACT_INVALID_WINDOW: "SUBSCRIPTION_CONTRACT_INVALID_WINDOW";
5359
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_TAX_MISMATCH";
5360
+ readonly SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT: "SUBSCRIPTION_CONTRACT_TAX_RATE_NOT_PERCENT";
5361
+ readonly SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH: "SUBSCRIPTION_CONTRACT_LINE_ITEM_CURRENCY_MISMATCH";
3885
5362
  readonly SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START: "SUBSCRIPTION_CONTRACT_TERMINATION_BEFORE_START";
3886
5363
  readonly CHECKOUT_OFFER_NOT_FOUND: "CHECKOUT_OFFER_NOT_FOUND";
3887
5364
  readonly CHECKOUT_OFFER_EXPIRED: "CHECKOUT_OFFER_EXPIRED";
3888
5365
  readonly CHECKOUT_OFFER_ALREADY_CONSUMED: "CHECKOUT_OFFER_ALREADY_CONSUMED";
3889
5366
  readonly CHECKOUT_OFFER_NOT_CONSUMED: "CHECKOUT_OFFER_NOT_CONSUMED";
5367
+ /**
5368
+ * The offer changed between the checks of `conclude` and the transaction
5369
+ * that consumed it, so the contract checked is not the one the offer now
5370
+ * describes. Nothing was written; load the offer and conclude it again.
5371
+ */
5372
+ readonly CHECKOUT_OFFER_CHANGED: "CHECKOUT_OFFER_CHANGED";
3890
5373
  readonly SUBSCRIPTION_CONTRACT_NOT_FOUND: "SUBSCRIPTION_CONTRACT_NOT_FOUND";
3891
5374
  readonly NO_ACTIVE_SUBSCRIPTION_CONTRACT: "NO_ACTIVE_SUBSCRIPTION_CONTRACT";
3892
5375
  readonly SUBSCRIPTION_CONTRACT_ALREADY_CLOSED: "SUBSCRIPTION_CONTRACT_ALREADY_CLOSED";
@@ -3899,6 +5382,8 @@ declare const PLATFORM_ERROR_CODES: {
3899
5382
  readonly BUNDLE_ALREADY_SUBSCRIBED: "BUNDLE_ALREADY_SUBSCRIBED";
3900
5383
  readonly BUNDLE_INCOMPATIBLE_WITH_PLAN: "BUNDLE_INCOMPATIBLE_WITH_PLAN";
3901
5384
  readonly BUNDLE_NOT_SELF_SERVICE: "BUNDLE_NOT_SELF_SERVICE";
5385
+ readonly BUNDLE_CYCLE_EXCEEDS_PLAN: "BUNDLE_CYCLE_EXCEEDS_PLAN";
5386
+ readonly BUNDLE_NOT_PRICED_FOR_THIS_PLAN: "BUNDLE_NOT_PRICED_FOR_THIS_PLAN";
3902
5387
  readonly SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED: "SUBSCRIPTION_BUNDLE_ALREADY_CANCELLED";
3903
5388
  readonly SUBSCRIPTION_BUNDLE_NOT_CANCELLED: "SUBSCRIPTION_BUNDLE_NOT_CANCELLED";
3904
5389
  readonly SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE: "SUBSCRIPTION_BUNDLE_CANCELLATION_EFFECTIVE";
@@ -3912,8 +5397,79 @@ declare const PLATFORM_ERROR_CODES: {
3912
5397
  readonly PLAN_NOT_IN_CATALOG: "PLAN_NOT_IN_CATALOG";
3913
5398
  /** Plan exists but cannot be booked via self-service. */
3914
5399
  readonly PLAN_NOT_SELF_SERVICE: "PLAN_NOT_SELF_SERVICE";
5400
+ /**
5401
+ * Plan carries no price for the requested billing cycle, so it is not sold
5402
+ * in it: a plan without a yearly price is a monthly plan.
5403
+ */
5404
+ readonly PLAN_NOT_SOLD_IN_CYCLE: "PLAN_NOT_SOLD_IN_CYCLE";
3915
5405
  /** Plan change refused. Carries `blockers[]` with their own codes. */
3916
5406
  readonly PLAN_CHANGE_BLOCKED: "PLAN_CHANGE_BLOCKED";
5407
+ /**
5408
+ * The subscription moved between the read a request was decided on and the
5409
+ * write it attempted, so nothing was written. The caller reloads and asks
5410
+ * again.
5411
+ */
5412
+ readonly SUBSCRIPTION_CHANGED: "SUBSCRIPTION_CHANGED";
5413
+ /**
5414
+ * The tenant has no subscription to act on.
5415
+ *
5416
+ * `SUBSCRIPTION_NOT_FOUND` states the same fact on the read routes. Both
5417
+ * are already on the wire and a code is renamed only deliberately, so both
5418
+ * are named here rather than one being dropped behind a consumer's back.
5419
+ */
5420
+ readonly NO_SUBSCRIPTION: "NO_SUBSCRIPTION";
5421
+ /**
5422
+ * The cancellation date the reader was shown is no longer the one the rules
5423
+ * return, so the confirmation is refused rather than silently applied.
5424
+ * Carries the recomputed dates, so the page can re-ask instead of guessing.
5425
+ */
5426
+ readonly CANCELLATION_TERMS_CHANGED: "CANCELLATION_TERMS_CHANGED";
5427
+ /** The subscription has ended; its plan can no longer be changed. */
5428
+ readonly SUBSCRIPTION_ENDED: "SUBSCRIPTION_ENDED";
5429
+ /** An active special contract blocks self-service plan changes. */
5430
+ readonly PLAN_LOCKED: "PLAN_LOCKED";
5431
+ /** Current usage of one quota exceeds what the target plan allows. */
5432
+ readonly QUOTA_OVER_TARGET: "QUOTA_OVER_TARGET";
5433
+ /** The change drops features the tenant has today. */
5434
+ readonly FEATURE_LOST: "FEATURE_LOST";
5435
+ readonly FEATURES_LOST: "FEATURES_LOST";
5436
+ /** Target plan and cycle already match what is in place. */
5437
+ readonly NO_CHANGE: "NO_CHANGE";
5438
+ /** A shorter cycle cannot start inside the term already running. */
5439
+ readonly CYCLE_SHORTENS_AT_TERM_END: "CYCLE_SHORTENS_AT_TERM_END";
5440
+ /** A cancelled subscription cannot change its billing cycle. */
5441
+ readonly CANCELLATION_LOCKS_THE_CYCLE: "CANCELLATION_LOCKS_THE_CYCLE";
5442
+ /**
5443
+ * A bundle the tenant already holds runs past the cycle they are moving to.
5444
+ *
5445
+ * Its own code rather than `BUNDLE_CYCLE_EXCEEDS_PLAN`, which states the
5446
+ * same rule about a booking that has not been made yet. The two need
5447
+ * different sentences: this one can name the day the obstacle lifts and
5448
+ * tell the reader to cancel the booking, and that advice is wrong for
5449
+ * someone who is only about to book. One template cannot serve both.
5450
+ */
5451
+ readonly BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE: "BUNDLE_BOOKING_OUTLASTS_TARGET_CYCLE";
5452
+ /**
5453
+ * Features of the previewed bundle are already covered by the plan or by
5454
+ * another booked bundle. A warning rather than a blocker: paying twice is
5455
+ * the customer's decision, and the preview only has to say so first.
5456
+ */
5457
+ readonly REDUNDANT_FEATURES: "REDUNDANT_FEATURES";
5458
+ /**
5459
+ * A booking's minimum term outlasts the period being cancelled, so the
5460
+ * cancellation takes effect at the end of the term, not of the period.
5461
+ */
5462
+ readonly MINIMUM_TERM_BINDS: "MINIMUM_TERM_BINDS";
5463
+ /**
5464
+ * The previewed bundle requires features that neither the plan nor an
5465
+ * active booking provides.
5466
+ *
5467
+ * The same string is a `StrictModeWarningCode` in `bundle.types.ts`, where
5468
+ * it names the catalogue-authoring reading of the rule and travels with its
5469
+ * own message. This declaration is the booking preview's blocker, which a
5470
+ * tenant reads and therefore needs a shipped text for.
5471
+ */
5472
+ readonly BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED: "BUNDLE_FEATURE_DEPENDENCY_UNSATISFIED";
3917
5473
  readonly NO_PENDING_PLAN_VERSION: "NO_PENDING_PLAN_VERSION";
3918
5474
  readonly ONBOARDING_CREATE_FAILED: "ONBOARDING_CREATE_FAILED";
3919
5475
  readonly BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS: "BUNDLE_PREVIEW_ARGUMENT_AMBIGUOUS";
@@ -3952,6 +5508,8 @@ declare const PLATFORM_ERROR_CODES: {
3952
5508
  readonly BUNDLE_VERSION_SUPERSEDED: "BUNDLE_VERSION_SUPERSEDED";
3953
5509
  readonly BUNDLE_VERSION_REGRESSION: "BUNDLE_VERSION_REGRESSION";
3954
5510
  readonly BUNDLE_VERSION_ZERO_PRICE: "BUNDLE_VERSION_ZERO_PRICE";
5511
+ readonly BUNDLE_VERSION_NO_PRICE: "BUNDLE_VERSION_NO_PRICE";
5512
+ readonly BUNDLE_VERSION_NOT_PRICED_FOR_PLAN: "BUNDLE_VERSION_NOT_PRICED_FOR_PLAN";
3955
5513
  readonly BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED: "BUNDLE_VERSION_DISCARD_NOT_IMPLEMENTED";
3956
5514
  readonly BUNDLE_VERSION_VALID_FROM_REQUIRED: "BUNDLE_VERSION_VALID_FROM_REQUIRED";
3957
5515
  readonly BUNDLE_VERSION_VALID_FROM_INVALID: "BUNDLE_VERSION_VALID_FROM_INVALID";
@@ -3979,6 +5537,10 @@ declare const PLATFORM_ERROR_CODES: {
3979
5537
  readonly QUOTA_NOT_IN_DISCOVERY_SNAPSHOT: "QUOTA_NOT_IN_DISCOVERY_SNAPSHOT";
3980
5538
  readonly DISCOVERY_STATUS_TRANSITION_INVALID: "DISCOVERY_STATUS_TRANSITION_INVALID";
3981
5539
  readonly DISCOVERY_NOT_INITIALIZED: "DISCOVERY_NOT_INITIALIZED";
5540
+ /** The uploaded document is not a plan catalog — unparseable, or not an object. */
5541
+ readonly PLAN_CATALOG_UNREADABLE: "PLAN_CATALOG_UNREADABLE";
5542
+ /** It parsed, and then failed the schema or a cross-field rule. */
5543
+ readonly PLAN_CATALOG_INVALID: "PLAN_CATALOG_INVALID";
3982
5544
  readonly PROMO_CODE_NOT_FOUND: "PROMO_CODE_NOT_FOUND";
3983
5545
  readonly PROMO_CODE_ALREADY_EXISTS: "PROMO_CODE_ALREADY_EXISTS";
3984
5546
  readonly PROMO_CODE_HAS_REDEMPTIONS: "PROMO_CODE_HAS_REDEMPTIONS";
@@ -4001,6 +5563,12 @@ declare const PLATFORM_ERROR_CODES: {
4001
5563
  /** Neither `tenantId` nor `userId` could be resolved from the request. */
4002
5564
  readonly TENANT_CONTEXT_MISSING: "TENANT_CONTEXT_MISSING";
4003
5565
  readonly TENANT_ADMIN_REQUIRED: "TENANT_ADMIN_REQUIRED";
5566
+ /**
5567
+ * The tenant's billing area — its payment method, and later its invoices
5568
+ * and account — needs the billing permission, which the application maps
5569
+ * to its roles and the tenant's administrator holds by default.
5570
+ */
5571
+ readonly BILLING_PERMISSION_REQUIRED: "BILLING_PERMISSION_REQUIRED";
4004
5572
  readonly SUPER_ADMIN_REQUIRED: "SUPER_ADMIN_REQUIRED";
4005
5573
  /** TOTP MFA has never been set up for this user. */
4006
5574
  readonly MFA_NOT_SET_UP: "MFA_NOT_SET_UP";
@@ -4021,7 +5589,7 @@ declare const PLATFORM_ERROR_CODES: {
4021
5589
  /** Email already taken (mapped from `PlatformUserExistsError`). */
4022
5590
  readonly EMAIL_EXISTS: "EMAIL_EXISTS";
4023
5591
  };
4024
- type PlatformErrorCode = SetupErrorCode | AuthErrorCode | PromoErrorCode | CatalogErrorCode | BillingErrorCode | ContractErrorCode | RegistrationErrorCode;
5592
+ type PlatformErrorCode = SetupErrorCode | AuthErrorCode | PromoErrorCode | CatalogErrorCode | BillingErrorCode | ContractErrorCode | SubscriberErrorCode | RegistrationErrorCode | PaymentErrorCode | SettingsErrorCode;
4025
5593
  /**
4026
5594
  * Shape of a coded error response.
4027
5595
  *
@@ -4242,7 +5810,25 @@ interface PendingRegistration {
4242
5810
  billingCycle: 'MONTHLY' | 'YEARLY' | null;
4243
5811
  /** Plaintext code (UI display). Validation runs fresh every time. */
4244
5812
  appliedPromoCode: string | null;
5813
+ /**
5814
+ * The billing address and tax identifiers step 4 asks for, which the
5815
+ * subscriber is created with. The address is required before a payment
5816
+ * method is set up; the tax identifiers stay optional.
5817
+ */
5818
+ addressLine1: string | null;
5819
+ addressLine2: string | null;
5820
+ postalCode: string | null;
5821
+ city: string | null;
5822
+ /** ISO 3166-1 alpha-2, upper case. */
5823
+ country: string | null;
5824
+ vatId: string | null;
5825
+ taxNumber: string | null;
5826
+ /** The gateway's session for the payment method, unique within `checkoutGatewayAccount`. */
4245
5827
  checkoutSessionId: string | null;
5828
+ /** The account in `config/saas.yaml#payments.accounts` the session was opened at. */
5829
+ checkoutGatewayAccount: string | null;
5830
+ /** The customer the gateway created for the sign-up, reused when step 4 is repeated. */
5831
+ gatewayCustomerRef: string | null;
4246
5832
  checkoutStartedAt: Date | null;
4247
5833
  expiresAt: Date;
4248
5834
  createdAt: Date;
@@ -4274,7 +5860,16 @@ interface PendingRegistrationUpdateInput {
4274
5860
  configJson?: RegistrationConfigSelection | null;
4275
5861
  billingCycle?: 'MONTHLY' | 'YEARLY' | null;
4276
5862
  appliedPromoCode?: string | null;
5863
+ addressLine1?: string | null;
5864
+ addressLine2?: string | null;
5865
+ postalCode?: string | null;
5866
+ city?: string | null;
5867
+ country?: string | null;
5868
+ vatId?: string | null;
5869
+ taxNumber?: string | null;
4277
5870
  checkoutSessionId?: string | null;
5871
+ checkoutGatewayAccount?: string | null;
5872
+ gatewayCustomerRef?: string | null;
4278
5873
  checkoutStartedAt?: Date | null;
4279
5874
  expiresAt?: Date;
4280
5875
  }
@@ -4282,14 +5877,24 @@ interface PendingRegistrationUpdateInput {
4282
5877
  interface PendingRegistrationRepository {
4283
5878
  findById(id: string): Promise<PendingRegistration | null>;
4284
5879
  findByEmail(email: string): Promise<PendingRegistration | null>;
4285
- /** Webhook lookup: finds the pending record for the provider session. */
4286
- findByCheckoutSession(sessionId: string): Promise<PendingRegistration | null>;
5880
+ /**
5881
+ * Finds the pending record a gateway session belongs to. A session
5882
+ * identifier is unique only within its account, so both are matched.
5883
+ */
5884
+ findByCheckoutSession(gatewayAccount: string, sessionId: string): Promise<PendingRegistration | null>;
4287
5885
  /**
4288
5886
  * Cleanup lookup: all pending records with `expiresAt < now`, max
4289
5887
  * `limit` entries per call (batch protection). Ordering irrelevant, the
4290
5888
  * cron service iterates sequentially.
4291
5889
  */
4292
5890
  findExpired(now: Date, limit: number): Promise<PendingRegistration[]>;
5891
+ /**
5892
+ * Every gateway account a checkout session is still open at: the distinct
5893
+ * `checkoutGatewayAccount` of records in `CHECKOUT_STARTED` whose
5894
+ * `expiresAt` is after `now`. The start refuses when one of them is no
5895
+ * longer configured, because that sign-up's confirmation could not arrive.
5896
+ */
5897
+ findOpenCheckoutAccounts(now: Date): Promise<string[]>;
4293
5898
  create(input: PendingRegistrationCreateInput): Promise<PendingRegistration>;
4294
5899
  update(id: string, input: PendingRegistrationUpdateInput): Promise<PendingRegistration>;
4295
5900
  /**
@@ -4299,7 +5904,13 @@ interface PendingRegistrationRepository {
4299
5904
  * value is the authoritative threshold for the lockout check.
4300
5905
  */
4301
5906
  incrementOtpAttemptCount(id: string): Promise<number>;
4302
- delete(id: string): Promise<void>;
5907
+ /**
5908
+ * Removes the record. With `tx` it is removed on that transaction and comes
5909
+ * back with its rollback — an activation deletes the sign-up on the
5910
+ * transaction that creates the tenant, so a sign-up is either still waiting
5911
+ * or activated, never both.
5912
+ */
5913
+ delete(id: string, tx?: TransactionContext): Promise<void>;
4303
5914
  }
4304
5915
  /** Adapter port: detects whether a full user account (verified) exists for this email. */
4305
5916
  interface UserAccountLookup {
@@ -4309,67 +5920,42 @@ interface UserAccountLookup {
4309
5920
  interface SlugAvailabilityCheck {
4310
5921
  isSlugAvailable(slug: string): Promise<boolean>;
4311
5922
  }
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
5923
  interface FinalActivationResult {
4345
5924
  userId: string;
4346
5925
  tenantId: string;
4347
5926
  subscriptionId: string;
5927
+ /**
5928
+ * The subscriber created for the tenant in the same transaction — the party
5929
+ * its contracts are concluded with. `subscriberFromRegistration(pending)`
5930
+ * says what it is created with.
5931
+ */
5932
+ subscriberId: string;
5933
+ }
5934
+ /** The transaction a sign-up is activated on. */
5935
+ interface RegistrationActivation {
5936
+ /**
5937
+ * Opened by the platform, which has already claimed the gateway's
5938
+ * confirmation on it and records the confirmed payment method on it once
5939
+ * `activate` returns. Every row the activation writes goes through it, so
5940
+ * a failure anywhere rolls back all of it — the claim included, and the
5941
+ * gateway's retry is handled rather than discarded as a duplicate.
5942
+ */
5943
+ tx: TransactionContext;
4348
5944
  }
4349
5945
  /**
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).
5946
+ * Adapter port: creates User + Tenant + Subscriber + Subscription once the
5947
+ * gateway confirmed the sign-up's payment method. App-specific — each app has
5948
+ * its own schema (e.g. Tenant + TenantUser + Role + UserRole + Subscription).
4354
5949
  *
4355
- * Implementations MUST perform the creation in a DB transaction so that
4356
- * partial creations are fully rolled back on errors.
5950
+ * Implementations write on `activation.tx` and open no transaction of their
5951
+ * own: a write beside it would survive the rollback that undoes the rest. The
5952
+ * subscriber is created there too, before any contract:
5953
+ * `SubscriberService.createForTenant(tenantId, subscriberFromRegistration(pending),
5954
+ * activation.tx)`, or `CheckoutOfferService.conclude` with `subscriber` and
5955
+ * that transaction.
4357
5956
  */
4358
5957
  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;
5958
+ activate(pending: PendingRegistration, activation: RegistrationActivation): Promise<FinalActivationResult>;
4373
5959
  }
4374
5960
  interface CleanupResult {
4375
5961
  /** Number of deleted PendingRegistration records. */
@@ -4380,7 +5966,7 @@ interface CleanupResult {
4380
5966
  */
4381
5967
  moreAvailable: boolean;
4382
5968
  }
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';
5969
+ 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
5970
  /**
4385
5971
  * Context information that the audit layer records per event.
4386
5972
  * IP is expected as a hashed fingerprint — no plaintext IPs in the
@@ -4431,8 +6017,6 @@ interface ConfiguratorModel {
4431
6017
  popular?: boolean;
4432
6018
  }
4433
6019
  interface ConfiguratorCatalog {
4434
- /** Factor `yearlyNet = monthlyNet * cycleDiscount` (typically 10 = 2 months free). */
4435
- cycleDiscount: number;
4436
6020
  currency: string;
4437
6021
  vatRate: number;
4438
6022
  models: ConfiguratorModel[];
@@ -4457,6 +6041,10 @@ interface ConfiguratorPriceBreakdown {
4457
6041
  modelMonthlyNet: number;
4458
6042
  subtotalMonthlyNet: number;
4459
6043
  subtotalNet: number;
6044
+ /**
6045
+ * Net amount taken off `subtotalNet`: the promo preview's gross discount
6046
+ * converted at `vatRate`.
6047
+ */
4460
6048
  discountAmount: number;
4461
6049
  totalNet: number;
4462
6050
  vatRate: number;
@@ -4533,8 +6121,6 @@ interface ConfiguratorPlanMarketing {
4533
6121
  */
4534
6122
  interface ConfiguratorMarketingProvider {
4535
6123
  listPlanMarketing(): ConfiguratorPlanMarketing[];
4536
- /** Factor `yearlyNet = monthlyNet * cycleDiscount`. Default `10`. */
4537
- getCycleDiscount(): number;
4538
6124
  getVatRate(): number;
4539
6125
  getCurrency(): string;
4540
6126
  }
@@ -4555,6 +6141,7 @@ interface RegistrationPromoPreview {
4555
6141
  reason?: string;
4556
6142
  percent?: number;
4557
6143
  label?: string;
6144
+ /** The discount in gross, reckoned against `subtotalGross`. */
4558
6145
  discountAmount?: number;
4559
6146
  }>;
4560
6147
  }
@@ -4626,6 +6213,8 @@ interface PendingRegistrationSnapshot {
4626
6213
  config: RegistrationConfigSelection | null;
4627
6214
  billingCycle: 'MONTHLY' | 'YEARLY' | null;
4628
6215
  appliedPromoCode: string | null;
6216
+ /** The billing details step 4 already took, to fill its form again. */
6217
+ billingDetails: RegistrationBillingDetails | null;
4629
6218
  checkoutSessionId: string | null;
4630
6219
  }
4631
6220
  interface ResumeRegistrationResult {
@@ -4634,27 +6223,6 @@ interface ResumeRegistrationResult {
4634
6223
  nextStep: RegistrationStep;
4635
6224
  snapshot: PendingRegistrationSnapshot;
4636
6225
  }
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
6226
  /** Adapter port: OTP delivery via email (or another channel). */
4659
6227
  interface RegistrationOtpDelivery {
4660
6228
  sendVerificationOtp(params: {
@@ -4720,9 +6288,27 @@ interface SelectPlanResult {
4720
6288
  nextStep: RegistrationStep;
4721
6289
  selectedPlanId: string;
4722
6290
  }
6291
+ /**
6292
+ * The billing address and tax identifiers a sign-up gives in step 4, which its
6293
+ * subscriber is created with. The address is required; the tax identifiers
6294
+ * stay optional until the tax adapter says when one is needed.
6295
+ */
6296
+ interface RegistrationBillingDetails {
6297
+ addressLine1: string;
6298
+ addressLine2?: string | null;
6299
+ postalCode: string;
6300
+ city: string;
6301
+ /** ISO 3166-1 alpha-2, upper case. */
6302
+ country: string;
6303
+ vatId?: string | null;
6304
+ taxNumber?: string | null;
6305
+ }
4723
6306
  interface StartCheckoutInput {
4724
6307
  pendingRegistrationId: string;
6308
+ billingDetails: RegistrationBillingDetails;
6309
+ /** Where the gateway's form sends the person once the payment method is set up. */
4725
6310
  successUrl: string;
6311
+ /** Where the gateway's form sends the person who leaves it. */
4726
6312
  cancelUrl: string;
4727
6313
  }
4728
6314
  interface StartCheckoutResult {
@@ -4766,6 +6352,306 @@ interface SetupConfirmMfaResponse {
4766
6352
  ok: boolean;
4767
6353
  }
4768
6354
 
6355
+ /** What the adapter's schema can actually answer about a version's dates. */
6356
+ interface PlanVersionMappingFields {
6357
+ /** `validFrom`/`validUntil` are maintained; otherwise both read as null. */
6358
+ validityWindows: boolean;
6359
+ /** `endsAt` exists; otherwise the field is left off the record entirely. */
6360
+ endsAt: boolean;
6361
+ }
6362
+ /** A `plans` row as either adapter reads it back. */
6363
+ interface CanonicalPlanRow {
6364
+ id: string;
6365
+ planKey: string;
6366
+ label: string;
6367
+ description: string | null;
6368
+ icon: string | null;
6369
+ sortOrder: number;
6370
+ createdAt: Date;
6371
+ updatedAt: Date;
6372
+ deletedAt: Date | null;
6373
+ }
6374
+ /** A `plan_versions` row as either adapter reads it back. */
6375
+ interface CanonicalPlanVersionRow {
6376
+ id: string;
6377
+ version: number;
6378
+ baseVersionId: string | null;
6379
+ features: unknown;
6380
+ quotas: unknown;
6381
+ monthlyNet: unknown;
6382
+ yearlyNet: unknown;
6383
+ marketed: boolean;
6384
+ publishedAt: Date | null;
6385
+ supersededAt: Date | null;
6386
+ publishedChanges: unknown;
6387
+ changeNote: string;
6388
+ nonRegressive: boolean;
6389
+ validFrom?: Date | null;
6390
+ validUntil?: Date | null;
6391
+ endsAt?: Date | null;
6392
+ createdByUserId: string | null;
6393
+ publishedByUserId: string | null;
6394
+ createdAt: Date;
6395
+ updatedAt: Date;
6396
+ }
6397
+ declare function toPlanRow(row: CanonicalPlanRow): PlanRow;
6398
+ /**
6399
+ * `planKey` is passed rather than read off the row: the canonical schema stores
6400
+ * the plan key in `planId`, but an adapter translating a consumer schema with a
6401
+ * real foreign key has to resolve it first, and only the adapter knows which
6402
+ * shape it is looking at.
6403
+ */
6404
+ declare function toPlanVersionRow(row: CanonicalPlanVersionRow, planKey: string, fields: PlanVersionMappingFields): PlanVersionRow;
6405
+
6406
+ /**
6407
+ * The legal identity of the issuer a catalogue names, or none where it names no
6408
+ * issuer.
6409
+ *
6410
+ * Settled through the same reader as the recorded side, and that symmetry is
6411
+ * the point rather than tidiness: the record is a verbatim copy of the file, so
6412
+ * a value whose two sides were settled differently would differ from itself. A
6413
+ * legal name written with a trailing space would then refuse the SECOND start on
6414
+ * a file nobody touched, and no declaration could make it stop happening.
6415
+ */
6416
+ declare function issuerIdentityOf(issuer: PlanCatalogIssuer | undefined): LegalIdentity | null;
6417
+ /**
6418
+ * The issuer identity a recorded settings tree holds.
6419
+ *
6420
+ * Read defensively rather than cast: the record is JSON as some earlier version
6421
+ * of this platform wrote it, and a tree without an issuer, or with one whose
6422
+ * legal name is not a name, is read as "no identity was recorded" — which is
6423
+ * the first naming, not a change. The schema keeps a name of only whitespace out
6424
+ * of the file; this keeps one out of a record written before it did.
6425
+ */
6426
+ declare function recordedIssuerIdentity(settings: AppliedSettingsValues | null | undefined): LegalIdentity | null;
6427
+ /** Why `issuer.correctionOf` does not cover the change it is there to declare. */
6428
+ type IssuerCorrectionFault =
6429
+ /** There is no declaration at all. */
6430
+ {
6431
+ kind: 'absent';
6432
+ }
6433
+ /**
6434
+ * The file names no issuer for a declaration to be about — the block is
6435
+ * gone, or it is there with a name that reads as nothing. Never a
6436
+ * correction, whatever is declared: there is no entity on this side for the
6437
+ * recorded one to be the same as.
6438
+ */
6439
+ | {
6440
+ kind: 'names-no-issuer';
6441
+ }
6442
+ /**
6443
+ * It names a value the record does not hold. Either the declaration is
6444
+ * stale — it belongs to a correction already applied — or it is about
6445
+ * another entity than the one this installation recorded.
6446
+ */
6447
+ | {
6448
+ kind: 'names-another-value';
6449
+ field: LegalIdentityField;
6450
+ declared: string | null;
6451
+ recorded: string | null;
6452
+ }
6453
+ /** It says nothing about a field the change moves, so that field is undeclared. */
6454
+ | {
6455
+ kind: 'leaves-a-field-out';
6456
+ field: LegalIdentityField;
6457
+ recorded: string | null;
6458
+ };
6459
+ /** What a start finds when it compares the file's issuer with the recorded one. */
6460
+ type IssuerIdentityChange =
6461
+ /** Neither the record nor the file names an issuer. */
6462
+ {
6463
+ kind: 'none-named';
6464
+ }
6465
+ /** The same entity, whatever the address and the contact details did. */
6466
+ | {
6467
+ kind: 'unchanged';
6468
+ identity: LegalIdentity;
6469
+ }
6470
+ /**
6471
+ * The first issuer this installation names. No contract can have been
6472
+ * concluded under another one, so nothing is declared for it.
6473
+ */
6474
+ | {
6475
+ kind: 'first-naming';
6476
+ identity: LegalIdentity;
6477
+ }
6478
+ /** The same entity, corrected as the file declares. `current` is never absent. */
6479
+ | {
6480
+ kind: 'corrected';
6481
+ recorded: LegalIdentity;
6482
+ current: LegalIdentity;
6483
+ moved: readonly LegalIdentityField[];
6484
+ reason: string;
6485
+ }
6486
+ /** Another identity, with no declaration that covers it. Refused. */
6487
+ | {
6488
+ kind: 'undeclared';
6489
+ recorded: LegalIdentity;
6490
+ /** `null` where the file names no issuer that has a name — see `names-no-issuer`. */
6491
+ current: LegalIdentity | null;
6492
+ moved: readonly LegalIdentityField[];
6493
+ fault: IssuerCorrectionFault;
6494
+ };
6495
+ /**
6496
+ * What the issuer in the file is, against the identity the record holds.
6497
+ *
6498
+ * `recorded` is `null` on the very first start, and on an installation that
6499
+ * has never named an issuer.
6500
+ */
6501
+ declare function classifyIssuerChange(recorded: LegalIdentity | null, issuer: PlanCatalogIssuer | undefined): IssuerIdentityChange;
6502
+
6503
+ /** A `subscribers` row as either adapter reads it back. */
6504
+ interface CanonicalSubscriberRow {
6505
+ id: string;
6506
+ customerSequence: number;
6507
+ customerNumberPrefix: string;
6508
+ legalName: string;
6509
+ addressLine1: string | null;
6510
+ addressLine2: string | null;
6511
+ postalCode: string | null;
6512
+ city: string | null;
6513
+ country: string | null;
6514
+ vatId: string | null;
6515
+ taxNumber: string | null;
6516
+ invoiceEmail: string | null;
6517
+ migrated: boolean;
6518
+ createdAt: Date;
6519
+ updatedAt: Date;
6520
+ }
6521
+ /** A `subscriber_corrections` row as either adapter reads it back. */
6522
+ interface CanonicalSubscriberCorrectionRow {
6523
+ id: string;
6524
+ subscriberId: string;
6525
+ previous: unknown;
6526
+ corrected: unknown;
6527
+ reason: string;
6528
+ correctedBy: string;
6529
+ correctedAt: Date;
6530
+ }
6531
+ /**
6532
+ * The customer number a subscriber is known by: the prefix it was assigned
6533
+ * with, then the number. Kept apart in storage so the number orders and the
6534
+ * prefix stays what it was on the day it was assigned.
6535
+ */
6536
+ declare function formatCustomerNumber(prefix: string, sequence: number): string;
6537
+ /** `tenantId` is the tenant the subscriber's live link names, read beside the row. */
6538
+ declare function toSubscriberRecord(row: CanonicalSubscriberRow, tenantId: string | null): SubscriberRecord;
6539
+ declare function toSubscriberCorrectionRecord(row: CanonicalSubscriberCorrectionRow): SubscriberCorrectionRecord;
6540
+ /**
6541
+ * The part of a correction that changes something: every corrected field whose
6542
+ * stored value differs, with the value it replaces. Both adapters decide this
6543
+ * the same way, under the lock they read `current` with.
6544
+ */
6545
+ declare function identityCorrectionDelta(current: Pick<SubscriberRecord, LegalIdentityField>, corrected: SubscriberIdentityValues): SubscriberIdentityDelta;
6546
+ /**
6547
+ * Who a new contract is between: the subscriber as it stands, and the issuer as
6548
+ * the running configuration names it — or no issuer, where it names none.
6549
+ */
6550
+ declare function contractPartiesOf(subscriber: SubscriberRecord, issuer: PlanCatalog['issuer']): SubscriptionContractParties;
6551
+ /**
6552
+ * The subscriber a completed sign-up is created with: the name the tenant was
6553
+ * registered under as its legal name, the address the registration was
6554
+ * verified with as its invoice email, and the billing address and tax
6555
+ * identifiers step 4 took.
6556
+ */
6557
+ declare function subscriberFromRegistration(pending: Pick<PendingRegistration, 'tenantName' | 'email' | 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'country' | 'vatId' | 'taxNumber'>): NewSubscriberDetails;
6558
+
6559
+ /** The payment method types, in the order a form offers them. */
6560
+ declare const PAYMENT_METHOD_TYPES: readonly PaymentMethodType[];
6561
+ /** A `subscriber_payment_methods` row as either adapter reads it back. */
6562
+ interface CanonicalSubscriberPaymentMethodRow {
6563
+ id: string;
6564
+ subscriberId: string;
6565
+ gatewayAccount: string;
6566
+ provider: string;
6567
+ customerRef: string;
6568
+ paymentMethodRef: string;
6569
+ type: string;
6570
+ brand: string | null;
6571
+ last4: string;
6572
+ expiryMonth: number | null;
6573
+ expiryYear: number | null;
6574
+ country: string | null;
6575
+ bankCode: string | null;
6576
+ mandateReference: string | null;
6577
+ status: string;
6578
+ confirmedAt: Date;
6579
+ replacedAt: Date | null;
6580
+ createdAt: Date;
6581
+ }
6582
+ /**
6583
+ * Reads a row back as a record.
6584
+ *
6585
+ * The two text columns with a closed set of values are checked rather than
6586
+ * cast: a value written by hand or by an older release would otherwise reach a
6587
+ * screen as a type nobody handles.
6588
+ */
6589
+ declare function toSubscriberPaymentMethodRecord(row: CanonicalSubscriberPaymentMethodRow): SubscriberPaymentMethodRecord;
6590
+ /** The columns a confirmed payment method is written with, and nothing a caller added beside them. */
6591
+ declare function subscriberPaymentMethodColumns(data: RecordSubscriberPaymentMethodData): Omit<CanonicalSubscriberPaymentMethodRow, 'id' | 'status' | 'replacedAt' | 'createdAt'>;
6592
+
6593
+ /** A `subscription_contracts` row as either adapter reads it back. */
6594
+ interface CanonicalContractRow {
6595
+ id: string;
6596
+ tenantId: string;
6597
+ subscriberId: string;
6598
+ subscriberSnapshot: unknown;
6599
+ issuerSnapshot: unknown;
6600
+ partiesMigrated: boolean;
6601
+ status: string;
6602
+ effectiveFrom: Date;
6603
+ effectiveUntil: Date | null;
6604
+ originalOfferId: string | null;
6605
+ originalPlanVersionId: string | null;
6606
+ originalBundleVersionIds: unknown;
6607
+ entitlementSnapshot: unknown;
6608
+ priceSnapshot: unknown;
6609
+ promotionSnapshots: unknown;
6610
+ promoCodeSnapshots: unknown;
6611
+ termsSnapshot: unknown;
6612
+ createdAt: Date;
6613
+ updatedAt: Date;
6614
+ }
6615
+ /** A `contract_line_items` row as either adapter reads it back. */
6616
+ interface CanonicalContractLineItemRow {
6617
+ id: string;
6618
+ contractId: string;
6619
+ kind: string;
6620
+ sourceKey: string;
6621
+ sourceVersionId: string | null;
6622
+ titleSnapshot: string;
6623
+ descriptionSnapshot: string | null;
6624
+ quantity: number;
6625
+ unit: string | null;
6626
+ priceNet: unknown;
6627
+ priceGross: unknown;
6628
+ billingCycle: string;
6629
+ currency: string;
6630
+ taxRate: unknown;
6631
+ taxAmount: unknown;
6632
+ minimumTermUntil: Date | null;
6633
+ featuresSnapshot: unknown;
6634
+ quotaEffectsSnapshot: unknown;
6635
+ metadata: unknown;
6636
+ createdAt: Date;
6637
+ }
6638
+ declare function toSubscriptionContractRecord(row: CanonicalContractRow, lineItems: CanonicalContractLineItemRow[]): SubscriptionContractRecord;
6639
+ declare function toContractLineItemRecord(row: CanonicalContractLineItemRow): ContractLineItemRecord;
6640
+ /**
6641
+ * The columns the issuer check reads off a running contract. A narrow row of
6642
+ * its own, because that query selects four columns rather than the whole
6643
+ * contract with its lines: it runs at every start, and an installation with
6644
+ * thousands of running contracts should not load them to count them.
6645
+ */
6646
+ interface CanonicalRunningContractRow {
6647
+ id: string;
6648
+ tenantId: string;
6649
+ issuerSnapshot: unknown;
6650
+ effectiveFrom: Date;
6651
+ }
6652
+ /** One running contract, and the legal name on its issuer copy where it has one. */
6653
+ declare function toRunningContractIssuer(row: CanonicalRunningContractRow): RunningContractIssuer;
6654
+
4769
6655
  /** Why a version is editable (for UI badges + audit logs). */
4770
6656
  type VersionEditableReason = 'draft' | 'pre-active';
4771
6657
  interface VersionEditability {
@@ -4780,6 +6666,30 @@ interface VersionEditability {
4780
6666
  */
4781
6667
  declare function isVersionEditable(v: VersionedEntityBase, now?: Date): VersionEditability;
4782
6668
 
6669
+ /** The part of a plan card this rule reads and writes. */
6670
+ interface RecommendablePlan {
6671
+ planKey: string;
6672
+ highlight: boolean;
6673
+ }
6674
+ /**
6675
+ * Leaves the mark on at most one plan, in place, and returns the winner.
6676
+ *
6677
+ * `inRequestedLocale` holds the keys of the plans whose card was described in
6678
+ * the language that was asked for. Where a caller has no fallback to model —
6679
+ * the SuperAdmin edits one language at a time — passing every key, or none,
6680
+ * gives the same answer: the first plan in the list order wins.
6681
+ *
6682
+ * The list order is the caller's, and it is what the reader sees, so the
6683
+ * answer is the first recommended card on the page. Said exactly, because it
6684
+ * is easy to overstate: the tie-break inherits whatever order the caller
6685
+ * arranged, and where two plans are equal by every criterion it sorted on,
6686
+ * that order is the repository's. `PlanRepository.list` promises none, so an
6687
+ * adapter that returns rows in a different order each time would move the mark
6688
+ * between two otherwise indistinguishable plans. The shipped adapters order
6689
+ * totally.
6690
+ */
6691
+ declare function keepOneRecommended<T extends RecommendablePlan>(plans: T[], inRequestedLocale: ReadonlySet<string>): T | null;
6692
+
4783
6693
  declare const ERROR_MESSAGES_EN: Record<PlatformErrorCode, string>;
4784
6694
  /** Values available for interpolation into a message template. */
4785
6695
  type ErrorMessageParams = Record<string, unknown>;
@@ -4789,6 +6699,23 @@ type ErrorMessageParams = Record<string, unknown>;
4789
6699
  * vanishing.
4790
6700
  */
4791
6701
  declare function formatErrorMessage(template: string, params?: ErrorMessageParams): string;
6702
+ /**
6703
+ * An error body this function can read.
6704
+ *
6705
+ * `code` is widened past `PlatformErrorCode` on purpose. The function checks
6706
+ * `typeof body.code === 'string'` and resolves whatever it finds, and the
6707
+ * `overrides` parameter exists so a consumer can bring its own codes — one
6708
+ * consumer carries 98 of them against the platform's 135, overlapping in five.
6709
+ * A closed union here would reject exactly the case the parameter is for, and
6710
+ * the cast that works around it is one a reader has to be told is deliberate.
6711
+ *
6712
+ * `PlatformErrorBody` stays closed: a body the *platform* produces really does
6713
+ * carry a platform code. It is the reader that has to accept more. The same
6714
+ * shape appears in `SaLocale` next door, for the same reason.
6715
+ */
6716
+ type ResolvableErrorBody = Omit<Partial<PlatformErrorBody>, 'code'> & Record<string, unknown> & {
6717
+ code?: PlatformErrorCode | (string & {});
6718
+ };
4792
6719
  /**
4793
6720
  * Turns an error body into display text.
4794
6721
  *
@@ -4802,8 +6729,8 @@ declare function formatErrorMessage(template: string, params?: ErrorMessageParam
4802
6729
  * second, so a template may name either without the value being duplicated on
4803
6730
  * the wire.
4804
6731
  */
4805
- declare function resolveErrorMessage(body: Partial<PlatformErrorBody> & Record<string, unknown>, overrides?: Partial<Record<string, string>>, defaults?: Partial<Record<string, string>>): string;
6732
+ declare function resolveErrorMessage(body: ResolvableErrorBody, overrides?: Partial<Record<string, string>>, defaults?: Partial<Record<string, string>>): string;
4806
6733
 
4807
6734
  declare const ERROR_MESSAGES_DE: Record<PlatformErrorCode, string>;
4808
6735
 
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 };
6736
+ 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, 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 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 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 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 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, isPaymentCallbackRejectedError, isPlatformUserExistsError, isVersionEditable, issuerIdentityOf, keepOneRecommended, missingRequiresFor, movedIdentityFields, pickActivePromo, planCatalogSettingsOf, previousUtcDay, promoStatus, readQuotaRecord, readQuotaValue, recordedIssuerIdentity, resolveBundleAvailability, resolveErrorMessage, sameLegalIdentity, selectChargeableBundles, settingsSubtreeOf, startOfUtcDay, subscriberFromRegistration, subscriberPaymentMethodColumns, toBundleStemRow, toContractLineItemRecord, toPlanRow, toPlanVersionRow, toRunningContractIssuer, toSubscriberCorrectionRecord, toSubscriberPaymentMethodRecord, toSubscriberRecord, toSubscriptionContractRecord };