@saasicat/spec 1.0.0-rc.23 → 1.0.0-rc.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/acceptance/README.md +1 -2
  2. package/acceptance/plan-version/publish-does-not-touch-existing-contracts.yaml +2 -9
  3. package/admin-api.openapi.yaml +764 -11
  4. package/package.json +1 -1
  5. package/prisma-fragments/01-subscription.prisma +10 -17
  6. package/prisma-fragments/03-plan-versions.prisma +1 -1
  7. package/prisma-fragments/04-audit-log.prisma +2 -1
  8. package/prisma-fragments/05-bundle.prisma +4 -3
  9. package/prisma-fragments/08-subscription-contract.prisma +7 -0
  10. package/prisma-fragments/09-pending-registration.prisma +6 -0
  11. package/prisma-fragments/10-super-admin.prisma +1 -1
  12. package/prisma-fragments/11-subscription-bundle.prisma +12 -2
  13. package/prisma-fragments/13-subscriber.prisma +81 -8
  14. package/prisma-fragments/15-subscriber-ledger.prisma +1 -1
  15. package/prisma-fragments/17-subscription-notice.prisma +58 -0
  16. package/prisma-fragments/18-version-retirement.prisma +44 -0
  17. package/prisma-fragments/19-bundle-version-retirement.prisma +44 -0
  18. package/prisma-fragments/README.md +23 -19
  19. package/schemas/audit-event.schema.json +2 -2
  20. package/schemas/plan-catalog.schema.json +40 -4
  21. package/schemas/subscriber-ledger.schema.json +2 -1
  22. package/sql/1.0-a-correction-carries-its-order.postgres.sql +78 -0
  23. package/sql/1.0-a-newer-version-is-only-offered.postgres.sql +33 -0
  24. package/sql/1.0-a-retirement-is-announced.postgres.sql +43 -0
  25. package/sql/1.0-a-scheduled-change-keeps-its-quoted-version.postgres.sql +129 -0
  26. package/sql/1.0-a-sign-up-keeps-its-vat-id-check.postgres.sql +38 -0
  27. package/sql/1.0-a-subscriber-has-a-tax-origin.postgres.sql +113 -0
  28. package/sql/1.0-a-subscriber-is-told-once.postgres.sql +48 -0
  29. package/sql/1.0-an-add-on-retirement-is-announced.postgres.sql +44 -0
  30. package/sql/1.0-an-add-on-switch-waits-for-its-term.postgres.sql +52 -0
  31. package/sql/constraints.postgres.sql +10 -0
  32. package/sql/reference-schema.postgres.sql +137 -10
@@ -29,7 +29,7 @@ openapi: 3.1.0
29
29
 
30
30
  info:
31
31
  title: SaaS Platform SuperAdmin API
32
- version: 1.0.0-rc.23
32
+ version: 1.0.0-rc.25
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
@@ -352,11 +352,7 @@ paths:
352
352
  properties:
353
353
  planId: { type: string }
354
354
  billingCycle: { type: string, enum: [MONTHLY, YEARLY] }
355
- customLimits:
356
- type: object
357
- additionalProperties:
358
- type: integer
359
- description: 'quotaKey → maxValue (-1 = unlimited)'
355
+ customLimits: { $ref: '#/components/schemas/CustomLimits' }
360
356
  customMonthlyNet: { type: number, format: decimal }
361
357
  isPilot: { type: boolean }
362
358
  pilotEndsAt: { type: string, format: date-time }
@@ -391,6 +387,201 @@ paths:
391
387
  '404':
392
388
  description: No tenant with this slug (`TENANT_NOT_FOUND`)
393
389
 
390
+ /tenants/{slug}/subscriber:
391
+ parameters:
392
+ - $ref: '#/components/parameters/TenantSlug'
393
+ get:
394
+ tags: [subscriptions]
395
+ summary: The tenant's subscriber, its tax details and what holds its next contract back
396
+ description: >-
397
+ Served where the platform keeps subscribers and shows tenants
398
+ (`adminResources`); the manifest announces it as
399
+ `subscribers.read`. The standing is computed when it is read,
400
+ and only where a tax adapter decides; elsewhere `readiness` is
401
+ null.
402
+ responses:
403
+ '200':
404
+ description: The subscriber, or none
405
+ content:
406
+ application/json:
407
+ schema: { $ref: '#/components/schemas/AdminTenantSubscriber' }
408
+ '404':
409
+ description: No tenant with this slug (`TENANT_NOT_FOUND`)
410
+
411
+ /tenants/{slug}/subscriber/history:
412
+ parameters:
413
+ - $ref: '#/components/parameters/TenantSlug'
414
+ get:
415
+ tags: [subscriptions]
416
+ summary: Every correction, change of country or business status, and check of the subscriber, the latest first
417
+ description: >-
418
+ Served beside `/tenants/{slug}/subscriber`; the manifest
419
+ announces it as `subscribers.correct`. A correction that gives
420
+ another VAT number is named once, as the correction it was. Empty
421
+ for a tenant without a subscriber.
422
+ responses:
423
+ '200':
424
+ description: The history
425
+ content:
426
+ application/json:
427
+ schema:
428
+ type: object
429
+ required: [entries]
430
+ properties:
431
+ entries:
432
+ type: array
433
+ items:
434
+ $ref: '#/components/schemas/AdminSubscriberHistoryEntry'
435
+ '404':
436
+ description: No tenant with this slug (`TENANT_NOT_FOUND`)
437
+
438
+ /tenants/{slug}/subscriber/identity:
439
+ parameters:
440
+ - $ref: '#/components/parameters/TenantSlug'
441
+ post:
442
+ tags: [subscriptions]
443
+ summary: Correct the legal identity of the tenant's subscriber
444
+ description: >-
445
+ A correction of the same legal entity — a field left out keeps
446
+ its value, `null` clears a tax identifier — recorded with the
447
+ values it replaced, the reason and who made it. `kind` is the
448
+ operator's declaration: a takeover by another entity is refused
449
+ (`SUBSCRIBER_TAKEOVER_IS_A_TRANSFER`). Where a tax adapter
450
+ decides and the correction gives another VAT number, that
451
+ number is checked right after and the check recorded, whatever
452
+ it found; the answer says how it went.
453
+ x-mfa-required: true
454
+ requestBody:
455
+ required: true
456
+ content:
457
+ application/json:
458
+ schema:
459
+ type: object
460
+ required: [kind, reason]
461
+ properties:
462
+ kind: { type: string, enum: [correction, takeover] }
463
+ legalName: { type: string }
464
+ vatId: { type: string, nullable: true }
465
+ taxNumber: { type: string, nullable: true }
466
+ reason: { type: string, maxLength: 500 }
467
+ responses:
468
+ '200':
469
+ description: The subscriber as it now stands, and the check its new number got
470
+ content:
471
+ application/json:
472
+ schema: { $ref: '#/components/schemas/AdminSubscriberCorrected' }
473
+ '404':
474
+ description: No tenant with this slug (`TENANT_NOT_FOUND`)
475
+ '409':
476
+ description: The tenant has no subscriber (`SUBSCRIBER_REQUIRED`)
477
+ '422':
478
+ description: >-
479
+ A takeover, no reason, or a correction that changes
480
+ nothing (`SUBSCRIBER_TAKEOVER_IS_A_TRANSFER`,
481
+ `SUBSCRIBER_CORRECTION_REASON_REQUIRED`,
482
+ `SUBSCRIBER_CORRECTION_CHANGES_NOTHING`)
483
+
484
+ /tenants/{slug}/subscriber/business-status:
485
+ parameters:
486
+ - $ref: '#/components/parameters/TenantSlug'
487
+ post:
488
+ tags: [subscriptions]
489
+ summary: Record whether the tenant's subscriber acts as a business, and why
490
+ description: >-
491
+ Recorded as a change of the tax origin with its reason and who
492
+ made it; it applies from the next contract. Setting the status it
493
+ has records nothing.
494
+ x-mfa-required: true
495
+ requestBody:
496
+ required: true
497
+ content:
498
+ application/json:
499
+ schema:
500
+ type: object
501
+ required: [business, reason]
502
+ properties:
503
+ business: { type: boolean, nullable: true }
504
+ reason: { type: string, maxLength: 500 }
505
+ responses:
506
+ '200':
507
+ description: The subscriber as it now stands
508
+ content:
509
+ application/json:
510
+ schema: { $ref: '#/components/schemas/AdminSubscriberCorrected' }
511
+ '404':
512
+ description: No tenant with this slug (`TENANT_NOT_FOUND`)
513
+ '409':
514
+ description: The tenant has no subscriber (`SUBSCRIBER_REQUIRED`)
515
+ '422':
516
+ description: No reason (`SUBSCRIBER_BUSINESS_STATUS_REASON_REQUIRED`)
517
+
518
+ /tenants/{slug}/subscriber/vat-id-check:
519
+ parameters:
520
+ - $ref: '#/components/parameters/TenantSlug'
521
+ post:
522
+ tags: [subscriptions]
523
+ summary: Check the VAT number the tenant's subscriber holds, again
524
+ description: >-
525
+ Asks the service the tax adapter names and records a completed
526
+ check, valid or not; a check that does not complete records
527
+ nothing and says why. No second factor: nothing is entered, and
528
+ the answer is the service's.
529
+ responses:
530
+ '200':
531
+ description: The subscriber as it now stands, and what the check found
532
+ content:
533
+ application/json:
534
+ schema: { $ref: '#/components/schemas/AdminSubscriberCorrected' }
535
+ '404':
536
+ description: No tenant with this slug (`TENANT_NOT_FOUND`)
537
+ '409':
538
+ description: >-
539
+ The tenant has no subscriber, or no tax adapter names a
540
+ service (`SUBSCRIBER_REQUIRED`, `TAX_VAT_ID_CHECK_NOT_AVAILABLE`)
541
+ '422':
542
+ description: The subscriber holds no VAT number (`SUBSCRIBER_VAT_ID_MISSING`)
543
+
544
+ /subscribers/attention:
545
+ get:
546
+ tags: [subscriptions]
547
+ summary: Which of these tenants hold their subscriber back from its next contract
548
+ description: >-
549
+ Served beside `/tenants/{slug}/subscriber`; the manifest
550
+ announces `subscribers.attention` only where a tax adapter
551
+ decides, and without one the answer is always empty. A tenant
552
+ whose subscriber is ready, and one without a subscriber, are
553
+ left out.
554
+ parameters:
555
+ - name: tenantId
556
+ in: query
557
+ description: The tenants of the page shown; repeat the parameter for each.
558
+ schema:
559
+ type: array
560
+ maxItems: 200
561
+ items: { type: string, minLength: 1 }
562
+ style: form
563
+ explode: true
564
+ responses:
565
+ '200':
566
+ description: The tenants held back, each with why
567
+ content:
568
+ application/json:
569
+ schema:
570
+ type: object
571
+ required: [attention]
572
+ properties:
573
+ attention:
574
+ type: array
575
+ items:
576
+ type: object
577
+ required: [tenantId, readiness]
578
+ properties:
579
+ tenantId: { type: string }
580
+ readiness:
581
+ $ref: '#/components/schemas/SubscriberReadiness'
582
+ '400':
583
+ description: More than 200 tenants, or an empty tenant id
584
+
394
585
  # ────────────────────────────────────────────────────────
395
586
  # Users
396
587
  # ────────────────────────────────────────────────────────
@@ -816,8 +1007,9 @@ paths:
816
1007
  id: { type: string }
817
1008
  tenant:
818
1009
  type: object
819
- required: [slug, name]
1010
+ required: [id, slug, name]
820
1011
  properties:
1012
+ id: { type: string }
821
1013
  slug: { type: string }
822
1014
  name: { type: string }
823
1015
  plan: { type: string }
@@ -1000,7 +1192,7 @@ paths:
1000
1192
  parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1001
1193
  post:
1002
1194
  tags: [catalog]
1003
- summary: Retire a plan version (endsAt; without a successor version)
1195
+ summary: End a plan version's sale (endsAt; without a successor version)
1004
1196
  x-mfa-required: true
1005
1197
  requestBody:
1006
1198
  required: true
@@ -1014,6 +1206,154 @@ paths:
1014
1206
  }
1015
1207
  responses: { '200': { description: Terminated } }
1016
1208
 
1209
+ # ── Retiring a version for running subscriptions (packages/nest/src/billing/version-retirement*) ──
1210
+ #
1211
+ # Served where the manifest carries the capability `planVersions.retire`. The
1212
+ # subscriptions on a version no longer on sale move to a replacement on sale, each at
1213
+ # the first end of a term at least three calendar months after the announcement, and
1214
+ # may cancel without notice until then. Where the operator's terms are not confirmed to
1215
+ # allow it, both routes refuse with `RETIREMENT_TERMS_NOT_CONFIRMED` before reading anything.
1216
+
1217
+ /catalog/plan-versions/{id}/retirement:
1218
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1219
+ get:
1220
+ tags: [catalog]
1221
+ summary: What retiring the version for its running subscriptions would do now
1222
+ parameters:
1223
+ - name: replacement
1224
+ in: query
1225
+ required: true
1226
+ description: The plan version the subscriptions continue on
1227
+ schema: { type: string, format: uuid }
1228
+ responses:
1229
+ '200':
1230
+ description: >-
1231
+ Whom it reaches and when, whom it does not and why, and every blocker the
1232
+ announcement would refuse with
1233
+ content:
1234
+ application/json:
1235
+ schema: { $ref: '#/components/schemas/RetirementPreview' }
1236
+ '404':
1237
+ description: Either version does not exist (`PLAN_VERSION_NOT_FOUND`)
1238
+ '422':
1239
+ description: >-
1240
+ The terms are not confirmed (`RETIREMENT_TERMS_NOT_CONFIRMED`), or the
1241
+ replacement is the version itself (`RETIREMENT_REPLACEMENT_IS_RETIRED`)
1242
+ post:
1243
+ tags: [catalog]
1244
+ summary: Announce the retirement to every subscription it reaches
1245
+ description: >-
1246
+ The announcement and one notice per subscription are recorded in one
1247
+ transaction, then handed to the application's `SubscriptionNoticePort`; a notice
1248
+ that cannot be sent now is sent by the next run. Audited as PLAN_VERSION_RETIRE.
1249
+ x-mfa-required: true
1250
+ requestBody:
1251
+ required: true
1252
+ content:
1253
+ application/json:
1254
+ schema: { $ref: '#/components/schemas/RetirementAnnouncement' }
1255
+ responses:
1256
+ '201':
1257
+ description: Announced
1258
+ content:
1259
+ application/json:
1260
+ schema: { $ref: '#/components/schemas/RetirementAnnounced' }
1261
+ '409':
1262
+ description: >-
1263
+ The subscriptions it reaches are no longer the ones named
1264
+ (`RETIREMENT_PREVIEW_CHANGED`); the body carries the preview as it stands
1265
+ '422':
1266
+ description: >-
1267
+ The first blocker the preview reports (`RETIREMENT_*`), with every blocker
1268
+ beside it
1269
+
1270
+ /catalog/version-retirements:
1271
+ get:
1272
+ tags: [catalog]
1273
+ summary: Every retirement announced, the most recent first
1274
+ responses:
1275
+ '200':
1276
+ description: List
1277
+ content:
1278
+ application/json:
1279
+ schema:
1280
+ type: array
1281
+ items: { $ref: '#/components/schemas/VersionRetirement' }
1282
+
1283
+ # Served where the manifest carries the capability `bundleVersions.retire`. The bookings
1284
+ # on an add-on version no longer on sale continue on a version of the same add-on on
1285
+ # sale, each at the first end of its own period at least three calendar months after
1286
+ # its notice reached the subscriber, and may be cancelled without their minimum term
1287
+ # until then. Where the operator's terms are not confirmed to allow it, both routes
1288
+ # refuse with `RETIREMENT_TERMS_NOT_CONFIRMED` before reading anything.
1289
+
1290
+ /catalog/bundle-versions/{id}/retirement:
1291
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1292
+ get:
1293
+ tags: [catalog]
1294
+ summary: What retiring the add-on version for its running bookings would do now
1295
+ parameters:
1296
+ - name: replacement
1297
+ in: query
1298
+ required: true
1299
+ description: The version of the same add-on the bookings continue on
1300
+ schema: { type: string, format: uuid }
1301
+ responses:
1302
+ '200':
1303
+ description: >-
1304
+ Which bookings it reaches and when, which it does not and why, and every
1305
+ blocker the announcement would refuse with
1306
+ content:
1307
+ application/json:
1308
+ schema: { $ref: '#/components/schemas/BundleRetirementPreview' }
1309
+ '404':
1310
+ description: Either version does not exist (`BUNDLE_VERSION_NOT_FOUND`)
1311
+ '422':
1312
+ description: >-
1313
+ The terms are not confirmed (`RETIREMENT_TERMS_NOT_CONFIRMED`), or the
1314
+ replacement is the version itself (`RETIREMENT_REPLACEMENT_IS_RETIRED`)
1315
+ post:
1316
+ tags: [catalog]
1317
+ summary: Announce the add-on retirement to every booking it reaches
1318
+ description: >-
1319
+ The announcement and one notice per booking are recorded in one transaction,
1320
+ then handed to the application's `SubscriptionNoticePort` as
1321
+ `bundle-version-retired`; a notice that cannot be sent now is sent by the next
1322
+ run. Audited as BUNDLE_VERSION_RETIRE.
1323
+ x-mfa-required: true
1324
+ requestBody:
1325
+ required: true
1326
+ content:
1327
+ application/json:
1328
+ schema: { $ref: '#/components/schemas/BundleRetirementAnnouncement' }
1329
+ responses:
1330
+ '201':
1331
+ description: Announced
1332
+ content:
1333
+ application/json:
1334
+ schema: { $ref: '#/components/schemas/BundleRetirementAnnounced' }
1335
+ '409':
1336
+ description: >-
1337
+ The bookings it reaches are no longer the ones named
1338
+ (`RETIREMENT_PREVIEW_CHANGED`); the body carries the preview as it stands
1339
+ '422':
1340
+ description: >-
1341
+ The first blocker the preview reports (`BUNDLE_RETIREMENT_*`,
1342
+ `RETIREMENT_WITHIN_TWELVE_MONTHS`), with every blocker beside it
1343
+
1344
+ /catalog/bundle-version-retirements:
1345
+ get:
1346
+ tags: [catalog]
1347
+ summary: Every add-on retirement announced, the most recent first
1348
+ responses:
1349
+ '200':
1350
+ description: List, each with how far it has come over the bookings it reached
1351
+ content:
1352
+ application/json:
1353
+ schema:
1354
+ type: array
1355
+ items: { $ref: '#/components/schemas/BundleVersionRetirement' }
1356
+
1017
1357
  # ── Bundles ──
1018
1358
  /catalog/bundles:
1019
1359
  get:
@@ -1925,6 +2265,270 @@ components:
1925
2265
  properties:
1926
2266
  endsAt: { type: string, description: 'ISO-8601; must lie in the future' }
1927
2267
 
2268
+ RetiredVersionRef:
2269
+ type: object
2270
+ required: [planVersionId, planKey, version]
2271
+ properties:
2272
+ planVersionId: { type: string }
2273
+ planKey: { type: string }
2274
+ version: { type: integer }
2275
+
2276
+ RetirementSide:
2277
+ description: 'A version as the subscribers compare it; a price of null is a rhythm it is not sold in'
2278
+ allOf:
2279
+ - $ref: '#/components/schemas/RetiredVersionRef'
2280
+ - type: object
2281
+ required: [features, quotas, monthlyNet, yearlyNet, validUntil, endsAt]
2282
+ properties:
2283
+ features: { type: array, items: { type: string } }
2284
+ quotas: { type: object, additionalProperties: { type: number } }
2285
+ monthlyNet: { type: [number, 'null'] }
2286
+ yearlyNet: { type: [number, 'null'] }
2287
+ validUntil: { type: [string, 'null'] }
2288
+ endsAt: { type: [string, 'null'] }
2289
+
2290
+ RetirementPreview:
2291
+ type: object
2292
+ required: [retired, replacement, changes, asOf, reached, skipped, blockers]
2293
+ properties:
2294
+ retired: { $ref: '#/components/schemas/RetirementSide' }
2295
+ replacement: { $ref: '#/components/schemas/RetirementSide' }
2296
+ changes:
2297
+ type: array
2298
+ description: 'Every difference, retired to replacement, as the catalogue diff states it'
2299
+ items: { type: object }
2300
+ asOf: { type: string, description: 'ISO-8601: the moment it is computed for' }
2301
+ reached:
2302
+ type: array
2303
+ items:
2304
+ type: object
2305
+ required:
2306
+ [
2307
+ tenantId,
2308
+ subscriptionId,
2309
+ status,
2310
+ billingCycle,
2311
+ effectiveAt,
2312
+ lastDayToCancel,
2313
+ reachedRecently,
2314
+ ]
2315
+ properties:
2316
+ tenantId: { type: string }
2317
+ subscriptionId: { type: string }
2318
+ status: { type: string }
2319
+ billingCycle: { type: string }
2320
+ effectiveAt: { type: string, description: 'ISO-8601: a term end' }
2321
+ lastDayToCancel:
2322
+ { type: string, description: 'ISO date, UTC: the day before' }
2323
+ reachedRecently:
2324
+ {
2325
+ type: boolean,
2326
+ description: 'Reached by a retirement within twelve months',
2327
+ }
2328
+ skipped:
2329
+ type: array
2330
+ items:
2331
+ type: object
2332
+ required: [tenantId, subscriptionId, reason]
2333
+ properties:
2334
+ tenantId: { type: string }
2335
+ subscriptionId: { type: string }
2336
+ reason:
2337
+ {
2338
+ type: string,
2339
+ enum:
2340
+ [
2341
+ ended,
2342
+ cancelled-before,
2343
+ changes-before,
2344
+ no-term,
2345
+ already-told,
2346
+ ],
2347
+ }
2348
+ blockers:
2349
+ type: array
2350
+ description: 'Empty where it may be announced as it stands'
2351
+ items:
2352
+ type: object
2353
+ required: [code, message, params]
2354
+ properties:
2355
+ code: { type: string, description: 'RETIREMENT_*' }
2356
+ message: { type: string }
2357
+ params: { type: object }
2358
+
2359
+ RetirementAnnouncement:
2360
+ type: object
2361
+ required: [replacementPlanVersionId, subscriptionIds]
2362
+ properties:
2363
+ replacementPlanVersionId: { type: string, format: uuid }
2364
+ subscriptionIds:
2365
+ type: array
2366
+ description: 'The subscriptions the operator was shown in the preview'
2367
+ items: { type: string }
2368
+
2369
+ VersionRetirement:
2370
+ type: object
2371
+ required: [id, retired, replacement, announcedAt, announcedBy]
2372
+ properties:
2373
+ id: { type: string }
2374
+ retired: { $ref: '#/components/schemas/RetiredVersionRef' }
2375
+ replacement: { $ref: '#/components/schemas/RetiredVersionRef' }
2376
+ announcedAt: { type: string }
2377
+ announcedBy: { type: string }
2378
+
2379
+ RetirementAnnounced:
2380
+ type: object
2381
+ required: [retirement, told, failed]
2382
+ properties:
2383
+ retirement: { $ref: '#/components/schemas/VersionRetirement' }
2384
+ told: { type: integer, description: 'Notices sent at once' }
2385
+ failed: { type: integer, description: 'Notices the next run sends' }
2386
+
2387
+ RetiredBundleVersionRef:
2388
+ type: object
2389
+ required: [bundleVersionId, bundleKey, version]
2390
+ properties:
2391
+ bundleVersionId: { type: string }
2392
+ bundleKey: { type: string }
2393
+ version: { type: integer }
2394
+
2395
+ BundleVersionSide:
2396
+ description: >-
2397
+ An add-on version as the subscribers compare it, at its own prices; each booking
2398
+ is told the prices beside its plan. A price of null is a rhythm it is not sold in
2399
+ allOf:
2400
+ - $ref: '#/components/schemas/RetiredBundleVersionRef'
2401
+ - type: object
2402
+ required: [label, features, quotas, monthlyNet, yearlyNet]
2403
+ properties:
2404
+ label: { type: string }
2405
+ features: { type: array, items: { type: string } }
2406
+ quotas: { type: object, additionalProperties: { type: number } }
2407
+ monthlyNet: { type: [number, 'null'] }
2408
+ yearlyNet: { type: [number, 'null'] }
2409
+
2410
+ BundleRetirementPreview:
2411
+ type: object
2412
+ required: [retired, replacement, changes, asOf, reached, skipped, blockers]
2413
+ properties:
2414
+ retired: { $ref: '#/components/schemas/BundleVersionSide' }
2415
+ replacement: { $ref: '#/components/schemas/BundleVersionSide' }
2416
+ changes:
2417
+ type: array
2418
+ description: 'Every difference at the two versions own prices'
2419
+ items: { type: object }
2420
+ asOf: { type: string, description: 'ISO-8601: the moment it is computed for' }
2421
+ reached:
2422
+ type: array
2423
+ items:
2424
+ type: object
2425
+ required:
2426
+ [
2427
+ tenantId,
2428
+ subscriptionId,
2429
+ subscriptionBundleId,
2430
+ planKey,
2431
+ planCycle,
2432
+ billingCycle,
2433
+ effectiveAt,
2434
+ lastDayToCancel,
2435
+ reachedRecently,
2436
+ ]
2437
+ properties:
2438
+ tenantId: { type: string }
2439
+ subscriptionId: { type: string }
2440
+ subscriptionBundleId: { type: string }
2441
+ planKey: { type: string, description: 'The plan beside it at the date' }
2442
+ planCycle: { type: string }
2443
+ billingCycle: { type: string, description: 'The booking rhythm' }
2444
+ effectiveAt:
2445
+ { type: string, description: 'ISO-8601: an end of its period' }
2446
+ lastDayToCancel:
2447
+ { type: string, description: 'ISO date, UTC: the day before' }
2448
+ reachedRecently:
2449
+ {
2450
+ type: boolean,
2451
+ description: 'Its subscription was reached by a retirement within twelve months',
2452
+ }
2453
+ skipped:
2454
+ type: array
2455
+ items:
2456
+ type: object
2457
+ required: [tenantId, subscriptionId, subscriptionBundleId, reason]
2458
+ properties:
2459
+ tenantId: { type: string }
2460
+ subscriptionId: { type: string }
2461
+ subscriptionBundleId: { type: string }
2462
+ reason:
2463
+ {
2464
+ type: string,
2465
+ enum:
2466
+ [
2467
+ ended,
2468
+ cancelled-before,
2469
+ changes-before,
2470
+ no-term,
2471
+ already-told,
2472
+ ],
2473
+ }
2474
+ blockers:
2475
+ type: array
2476
+ description: 'Empty where it may be announced as it stands'
2477
+ items:
2478
+ type: object
2479
+ required: [code, message, params]
2480
+ properties:
2481
+ code: { type: string, description: 'BUNDLE_RETIREMENT_*, RETIREMENT_*' }
2482
+ message: { type: string }
2483
+ params: { type: object }
2484
+
2485
+ BundleRetirementAnnouncement:
2486
+ type: object
2487
+ required: [replacementBundleVersionId, subscriptionBundleIds]
2488
+ properties:
2489
+ replacementBundleVersionId: { type: string, format: uuid }
2490
+ subscriptionBundleIds:
2491
+ type: array
2492
+ description: 'The bookings the operator was shown in the preview'
2493
+ items: { type: string }
2494
+
2495
+ BundleVersionRetirementRecord:
2496
+ type: object
2497
+ required: [id, retired, replacement, announcedAt, announcedBy]
2498
+ properties:
2499
+ id: { type: string }
2500
+ retired: { $ref: '#/components/schemas/RetiredBundleVersionRef' }
2501
+ replacement: { $ref: '#/components/schemas/RetiredBundleVersionRef' }
2502
+ announcedAt: { type: string }
2503
+ announcedBy: { type: string }
2504
+
2505
+ BundleVersionRetirement:
2506
+ description: 'An add-on announcement as the list answers it, with how far it has come'
2507
+ allOf:
2508
+ - $ref: '#/components/schemas/BundleVersionRetirementRecord'
2509
+ - type: object
2510
+ required: [progress]
2511
+ properties:
2512
+ progress:
2513
+ type: object
2514
+ description: 'Counted over the bookings it reached'
2515
+ required: [moved, waiting, overdue, ended, notTold, reminded]
2516
+ properties:
2517
+ moved: { type: integer }
2518
+ waiting: { type: integer }
2519
+ overdue: { type: integer }
2520
+ ended: { type: integer }
2521
+ notTold: { type: integer }
2522
+ reminded: { type: integer }
2523
+
2524
+ BundleRetirementAnnounced:
2525
+ type: object
2526
+ required: [retirement, told, failed]
2527
+ properties:
2528
+ retirement: { $ref: '#/components/schemas/BundleVersionRetirementRecord' }
2529
+ told: { type: integer, description: 'Notices sent at once' }
2530
+ failed: { type: integer, description: 'Notices the next run sends' }
2531
+
1928
2532
  CatalogMarketingProjectionUpdate:
1929
2533
  type: object
1930
2534
  description: 'Partial update of a marketing projection'
@@ -2169,6 +2773,24 @@ components:
2169
2773
  sendWelcomeMail: { type: boolean, default: true }
2170
2774
 
2171
2775
  # ────────── Subscription ──────────
2776
+ CustomLimits:
2777
+ type: object
2778
+ description: >-
2779
+ A tenant's own limits beside its plan — a negotiated contract, a pilot
2780
+ (`CustomLimits` in @saasicat/core). A quota replaces the plan's value for that
2781
+ quota; a feature is added to the plan's. Stored as JSON in
2782
+ `subscriptions.customLimits`; a stored value in any other shape is not applied.
2783
+ additionalProperties: false
2784
+ properties:
2785
+ quotas:
2786
+ type: object
2787
+ additionalProperties:
2788
+ type: integer
2789
+ minimum: -1
2790
+ description: 'quotaKey → maxValue (-1 = unlimited)'
2791
+ features:
2792
+ type: array
2793
+ items: { type: string }
2172
2794
  SubscriptionDetail:
2173
2795
  type: object
2174
2796
  properties:
@@ -2181,9 +2803,7 @@ components:
2181
2803
  canceledAt: { type: string, format: date-time }
2182
2804
  isPilot: { type: boolean }
2183
2805
  pilotEndsAt: { type: string, format: date-time }
2184
- customLimits:
2185
- type: object
2186
- additionalProperties: { type: integer }
2806
+ customLimits: { $ref: '#/components/schemas/CustomLimits' }
2187
2807
  customMonthlyNet: { type: number, format: decimal, nullable: true }
2188
2808
  effectiveLimits:
2189
2809
  type: object
@@ -2227,6 +2847,139 @@ components:
2227
2847
  contract was concluded; null where that line
2228
2848
  cannot be read.
2229
2849
 
2850
+ SubscriberReadiness:
2851
+ type: object
2852
+ description: >-
2853
+ Whether a subscriber can be given its next contract where a tax
2854
+ adapter decides: the empty fields of the address an invoice
2855
+ names, and the adapter's sentence where it supports no
2856
+ treatment for the subscriber as it stands.
2857
+ required: [ready, missing, taxRefusal]
2858
+ properties:
2859
+ ready: { type: boolean }
2860
+ missing:
2861
+ type: array
2862
+ items:
2863
+ type: string
2864
+ enum: [addressLine1, postalCode, city, country]
2865
+ taxRefusal:
2866
+ type: string
2867
+ nullable: true
2868
+
2869
+ AdminTenantSubscriber:
2870
+ type: object
2871
+ required: [subscriber, readiness]
2872
+ properties:
2873
+ subscriber:
2874
+ type: object
2875
+ nullable: true
2876
+ description: Null for a tenant that has no subscriber.
2877
+ required:
2878
+ [
2879
+ id,
2880
+ customerNumber,
2881
+ legalName,
2882
+ addressLine1,
2883
+ addressLine2,
2884
+ postalCode,
2885
+ city,
2886
+ country,
2887
+ business,
2888
+ vatId,
2889
+ vatIdValidated,
2890
+ taxNumber,
2891
+ migrated,
2892
+ ]
2893
+ properties:
2894
+ id: { type: string }
2895
+ customerNumber: { type: string }
2896
+ legalName: { type: string }
2897
+ addressLine1: { type: string, nullable: true }
2898
+ addressLine2: { type: string, nullable: true }
2899
+ postalCode: { type: string, nullable: true }
2900
+ city: { type: string, nullable: true }
2901
+ country: { type: string, nullable: true }
2902
+ business: { type: boolean, nullable: true }
2903
+ vatId: { type: string, nullable: true }
2904
+ vatIdValidated:
2905
+ type: boolean
2906
+ description: Whether the check that counts for the VAT id found it valid.
2907
+ taxNumber: { type: string, nullable: true }
2908
+ migrated:
2909
+ type: boolean
2910
+ description: Created from the application's own tenant record.
2911
+ readiness:
2912
+ nullable: true
2913
+ description: Null where no tax adapter decides, or the tenant has no subscriber.
2914
+ allOf:
2915
+ - $ref: '#/components/schemas/SubscriberReadiness'
2916
+
2917
+ AdminVatIdCheckOutcome:
2918
+ description: >-
2919
+ A check of a VAT number as the operator is shown it — what the
2920
+ service found, or why the check did not complete.
2921
+ oneOf:
2922
+ - type: object
2923
+ required: [completed, valid, checkedAt, service, counts]
2924
+ properties:
2925
+ completed: { type: boolean, enum: [true] }
2926
+ valid: { type: boolean }
2927
+ checkedAt: { type: string, format: date-time }
2928
+ service: { type: string }
2929
+ counts:
2930
+ type: boolean
2931
+ description: Whether it is the check that counts for the number held now.
2932
+ - type: object
2933
+ required: [completed, reason]
2934
+ properties:
2935
+ completed: { type: boolean, enum: [false] }
2936
+ reason: { type: string }
2937
+
2938
+ AdminSubscriberCorrected:
2939
+ type: object
2940
+ required: [subscriber, vatIdCheck]
2941
+ properties:
2942
+ subscriber: { $ref: '#/components/schemas/AdminTenantSubscriber' }
2943
+ vatIdCheck:
2944
+ nullable: true
2945
+ description: The check a VAT number got, where one was made.
2946
+ allOf:
2947
+ - $ref: '#/components/schemas/AdminVatIdCheckOutcome'
2948
+
2949
+ AdminSubscriberHistoryEntry:
2950
+ description: One correction, change of the tax origin, or check of the VAT number.
2951
+ oneOf:
2952
+ - type: object
2953
+ required: [kind, at, by, previous, corrected, reason]
2954
+ properties:
2955
+ kind: { type: string, enum: [identity-corrected] }
2956
+ at: { type: string, format: date-time }
2957
+ by: { type: string }
2958
+ previous: { type: object, additionalProperties: true }
2959
+ corrected: { type: object, additionalProperties: true }
2960
+ reason: { type: string }
2961
+ - type: object
2962
+ required: [kind, at, by, previous, changed, reason]
2963
+ properties:
2964
+ kind: { type: string, enum: [tax-origin-changed] }
2965
+ at: { type: string, format: date-time }
2966
+ by: { type: string }
2967
+ previous: { type: object, additionalProperties: true }
2968
+ changed: { type: object, additionalProperties: true }
2969
+ reason:
2970
+ type: string
2971
+ nullable: true
2972
+ description: Null for a change of the country with the contact details.
2973
+ - type: object
2974
+ required: [kind, at, vatId, valid, service, counts]
2975
+ properties:
2976
+ kind: { type: string, enum: [vat-id-checked] }
2977
+ at: { type: string, format: date-time }
2978
+ vatId: { type: string }
2979
+ valid: { type: boolean }
2980
+ service: { type: string }
2981
+ counts: { type: boolean }
2982
+
2230
2983
  # ────────── User ──────────
2231
2984
  AdminUserRow:
2232
2985
  type: object