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

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/README.md CHANGED
@@ -33,7 +33,7 @@ from there.
33
33
  | `schemas/plan-catalog.schema.json` | App identity config format (`config/saas.yaml`) |
34
34
  | `schemas/promo-code.schema.json` | Promo code format incl. redemption lifecycle |
35
35
  | `schemas/audit-event.schema.json` | Audit log entry format |
36
- | `schemas/tenant-ledger.schema.json` | A tenant's account: charges, payments, balance |
36
+ | `schemas/subscriber-ledger.schema.json` | One charge in a subscriber's account |
37
37
  | `admin-api.openapi.yaml` | NORMATIVE REST contract of the SuperAdmin backend |
38
38
  | `acceptance/` | HTTP test scenarios (stubs; runner planned) |
39
39
  | `index.js` / `index.cjs` / `index.d.ts` | Re-exports for JS/TS consumers |
@@ -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.21
32
+ version: 1.0.0-rc.23
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
@@ -58,6 +58,7 @@ tags:
58
58
  - name: manifest
59
59
  - name: catalog
60
60
  - name: settings
61
+ - name: maintenance
61
62
 
62
63
  security:
63
64
  - bearerAuth: []
@@ -125,7 +126,7 @@ paths:
125
126
  content:
126
127
  application/json:
127
128
  schema:
128
- $ref: 'admin-manifest.schema.json'
129
+ $ref: 'schemas/admin-manifest.schema.json'
129
130
  '304':
130
131
  description: Unchanged since ETag
131
132
  '403':
@@ -368,6 +369,28 @@ paths:
368
369
  application/json:
369
370
  schema: { $ref: '#/components/schemas/SubscriptionDetail' }
370
371
 
372
+ /tenants/{slug}/charges:
373
+ parameters:
374
+ - $ref: '#/components/parameters/TenantSlug'
375
+ get:
376
+ tags: [subscriptions]
377
+ summary: The charges of the tenant's subscriber, newest first
378
+ description: >-
379
+ Served where the platform keeps a charge journal
380
+ (`tenantBilling.chargeJournal`) and shows tenants
381
+ (`adminResources`); the manifest announces it as `charges.read`.
382
+ Read only, and net: a charge's tax is decided when it is
383
+ invoiced. No total — without invoices and payments, a sum would
384
+ be read as what is owed.
385
+ responses:
386
+ '200':
387
+ description: The account
388
+ content:
389
+ application/json:
390
+ schema: { $ref: '#/components/schemas/AdminSubscriberAccount' }
391
+ '404':
392
+ description: No tenant with this slug (`TENANT_NOT_FOUND`)
393
+
371
394
  # ────────────────────────────────────────────────────────
372
395
  # Users
373
396
  # ────────────────────────────────────────────────────────
@@ -1384,6 +1407,146 @@ paths:
1384
1407
  '404':
1385
1408
  description: No recorded change has this id (`SETTINGS_CHANGE_NOT_FOUND`)
1386
1409
 
1410
+ # ── Maintenance windows (MaintenanceModule, packages/nest/src/maintenance/*) ──
1411
+ #
1412
+ # An operator announces a window, locks the application for a deploy and
1413
+ # unlocks it. The announced times are what tenants are told; they never
1414
+ # start or end the lock. These routes stay reachable while the lock holds.
1415
+ # The status a tenant's page reads is `GET /public/maintenance`, outside
1416
+ # this document's prefix.
1417
+
1418
+ /maintenance:
1419
+ get:
1420
+ tags: [maintenance]
1421
+ summary: The open window, if any, and the ones before it
1422
+ responses:
1423
+ '200':
1424
+ description: The overview, read fresh
1425
+ content:
1426
+ application/json:
1427
+ schema: { $ref: '#/components/schemas/MaintenanceOverview' }
1428
+ post:
1429
+ tags: [maintenance]
1430
+ summary: Announce a window to the tenants
1431
+ description: >-
1432
+ Locks nobody out. Refused while another window is open. The application hears
1433
+ of it through its `MaintenanceNotificationPort`, where it binds one. Audited as
1434
+ MAINTENANCE_WINDOW_ANNOUNCE.
1435
+ requestBody:
1436
+ required: true
1437
+ content:
1438
+ application/json:
1439
+ schema: { $ref: '#/components/schemas/MaintenanceAnnouncement' }
1440
+ responses:
1441
+ '201':
1442
+ description: The window, announced
1443
+ content:
1444
+ application/json:
1445
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1446
+ '400':
1447
+ description: >-
1448
+ A time without its zone (`MAINTENANCE_TIME_INVALID`), an end not after the
1449
+ start (`MAINTENANCE_WINDOW_END_NOT_AFTER_START`) or in the past
1450
+ (`MAINTENANCE_WINDOW_END_IN_PAST`), a message too long
1451
+ (`MAINTENANCE_MESSAGE_TOO_LONG`)
1452
+ '409':
1453
+ description: Another window is open (`MAINTENANCE_WINDOW_ALREADY_OPEN`)
1454
+
1455
+ /maintenance/{id}:
1456
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1457
+ patch:
1458
+ tags: [maintenance]
1459
+ summary: Move the open window, or change its message
1460
+ description: >-
1461
+ What is left out stays; `message: null` takes the message away. A locked window
1462
+ keeps its start and may still move its expected end. Audited as
1463
+ MAINTENANCE_WINDOW_RESCHEDULE.
1464
+ requestBody:
1465
+ required: true
1466
+ content:
1467
+ application/json:
1468
+ schema: { $ref: '#/components/schemas/MaintenanceRevision' }
1469
+ responses:
1470
+ '200':
1471
+ description: The window as it now stands
1472
+ content:
1473
+ application/json:
1474
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1475
+ '400':
1476
+ description: As for announcing
1477
+ '404':
1478
+ description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
1479
+ '409':
1480
+ description: The window is locked and its start cannot move (`MAINTENANCE_WINDOW_LOCKED`)
1481
+
1482
+ /maintenance/{id}/cancel:
1483
+ parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
1484
+ post:
1485
+ tags: [maintenance]
1486
+ summary: Call off an announced window
1487
+ description: Audited as MAINTENANCE_WINDOW_CANCEL. A locked window is ended by unlocking it.
1488
+ responses:
1489
+ '201':
1490
+ description: The window, cancelled
1491
+ content:
1492
+ application/json:
1493
+ schema: { $ref: '#/components/schemas/MaintenanceWindowView' }
1494
+ '404':
1495
+ description: No open window has this id (`MAINTENANCE_WINDOW_NOT_OPEN`)
1496
+ '409':
1497
+ description: The window is locked (`MAINTENANCE_WINDOW_LOCKED`)
1498
+
1499
+ /maintenance/lock:
1500
+ post:
1501
+ tags: [maintenance]
1502
+ summary: Lock every tenant out
1503
+ description: >-
1504
+ Locks the open window, or opens one locked at once where none is open. Locking what
1505
+ is locked changes nothing and says so. Takes effect in every process within
1506
+ `takesEffectWithinSeconds`. Audited as MAINTENANCE_LOCK.
1507
+ x-mfa-required: true
1508
+ requestBody:
1509
+ required: false
1510
+ content:
1511
+ application/json:
1512
+ schema: { $ref: '#/components/schemas/MaintenanceLockRequest' }
1513
+ responses:
1514
+ '201':
1515
+ description: The lock
1516
+ content:
1517
+ application/json:
1518
+ schema: { $ref: '#/components/schemas/MaintenanceLockView' }
1519
+ '400':
1520
+ description: An end without its zone or in the past
1521
+ '404':
1522
+ description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
1523
+
1524
+ /maintenance/unlock:
1525
+ post:
1526
+ tags: [maintenance]
1527
+ summary: Let tenants back in
1528
+ description: >-
1529
+ Ends the locked window. Where nothing is locked it changes nothing and says so; an
1530
+ announced window is left as it is. Audited as MAINTENANCE_UNLOCK.
1531
+ x-mfa-required: true
1532
+ requestBody:
1533
+ required: false
1534
+ content:
1535
+ application/json:
1536
+ schema:
1537
+ type: object
1538
+ properties:
1539
+ windowId:
1540
+ { type: string, description: The window the operator means }
1541
+ responses:
1542
+ '201':
1543
+ description: What the unlock did
1544
+ content:
1545
+ application/json:
1546
+ schema: { $ref: '#/components/schemas/MaintenanceUnlockView' }
1547
+ '404':
1548
+ description: The window named is not the open one (`MAINTENANCE_WINDOW_NOT_OPEN`)
1549
+
1387
1550
  # ── Discovery (scan/status, outside /catalog) ──
1388
1551
  /discovery:
1389
1552
  get:
@@ -1492,6 +1655,123 @@ components:
1492
1655
  before: { description: 'Absent where the leaf did not exist before' }
1493
1656
  after: { description: 'Absent where the leaf no longer exists' }
1494
1657
 
1658
+ # ────────── Maintenance windows (packages/core/src/maintenance-window.types.ts) ──────────
1659
+ MaintenanceWindowView:
1660
+ type: object
1661
+ required:
1662
+ [
1663
+ id,
1664
+ status,
1665
+ startsAt,
1666
+ endsAt,
1667
+ message,
1668
+ createdAt,
1669
+ createdBy,
1670
+ lockedAt,
1671
+ lockedBy,
1672
+ endedAt,
1673
+ endedBy,
1674
+ overrun,
1675
+ lapsed,
1676
+ ]
1677
+ properties:
1678
+ id: { type: string }
1679
+ status: { type: string, enum: [announced, locked, ended, cancelled] }
1680
+ startsAt:
1681
+ {
1682
+ type: [string, 'null'],
1683
+ format: date-time,
1684
+ description: 'The announced start; null for a window locked without being announced',
1685
+ }
1686
+ endsAt:
1687
+ {
1688
+ type: [string, 'null'],
1689
+ format: date-time,
1690
+ description: 'The announced, expected end; null where nobody said',
1691
+ }
1692
+ message: { type: [string, 'null'], description: 'Shown to the tenants as written' }
1693
+ createdAt: { type: string, format: date-time }
1694
+ createdBy: { type: string, description: 'An actor tag, as the audit log writes it' }
1695
+ lockedAt: { type: [string, 'null'], format: date-time }
1696
+ lockedBy: { type: [string, 'null'] }
1697
+ endedAt: { type: [string, 'null'], format: date-time }
1698
+ endedBy: { type: [string, 'null'] }
1699
+ overrun: { type: boolean, description: 'Locked, and past its announced end' }
1700
+ lapsed:
1701
+ {
1702
+ type: boolean,
1703
+ description: 'Announced, never locked, and past its announced end: tenants no longer see it',
1704
+ }
1705
+
1706
+ MaintenanceOverview:
1707
+ type: object
1708
+ required: [open, recent, takesEffectWithinSeconds]
1709
+ properties:
1710
+ open:
1711
+ oneOf:
1712
+ - $ref: '#/components/schemas/MaintenanceWindowView'
1713
+ - type: 'null'
1714
+ recent:
1715
+ type: array
1716
+ description: 'Newest first, the open one included'
1717
+ items: { $ref: '#/components/schemas/MaintenanceWindowView' }
1718
+ takesEffectWithinSeconds:
1719
+ { type: integer, description: 'How long a lock takes to reach every process' }
1720
+
1721
+ MaintenanceAnnouncement:
1722
+ type: object
1723
+ required: [startsAt, endsAt]
1724
+ properties:
1725
+ startsAt:
1726
+ {
1727
+ type: string,
1728
+ description: 'Date and time with its zone, e.g. 2026-10-02T22:00+02:00',
1729
+ }
1730
+ endsAt:
1731
+ {
1732
+ type: string,
1733
+ description: 'Date and time with its zone; after startsAt, and ahead',
1734
+ }
1735
+ message: { type: [string, 'null'], maxLength: 500 }
1736
+
1737
+ MaintenanceRevision:
1738
+ type: object
1739
+ properties:
1740
+ startsAt: { type: string, description: 'Date and time with its zone' }
1741
+ endsAt: { type: string, description: 'Date and time with its zone' }
1742
+ message:
1743
+ { type: [string, 'null'], maxLength: 500, description: 'null takes it away' }
1744
+
1745
+ MaintenanceLockRequest:
1746
+ type: object
1747
+ properties:
1748
+ windowId:
1749
+ {
1750
+ type: string,
1751
+ description: 'The window the operator means; left out, the open one or a new one',
1752
+ }
1753
+ endsAt: { type: string, description: 'The expected end, with its zone' }
1754
+ message: { type: [string, 'null'], maxLength: 500 }
1755
+
1756
+ MaintenanceLockView:
1757
+ type: object
1758
+ required: [window, alreadyLocked, takesEffectWithinSeconds]
1759
+ properties:
1760
+ window: { $ref: '#/components/schemas/MaintenanceWindowView' }
1761
+ alreadyLocked:
1762
+ { type: boolean, description: 'The lock already held; nothing changed' }
1763
+ takesEffectWithinSeconds: { type: integer }
1764
+
1765
+ MaintenanceUnlockView:
1766
+ type: object
1767
+ required: [window, wasLocked]
1768
+ properties:
1769
+ window:
1770
+ oneOf:
1771
+ - $ref: '#/components/schemas/MaintenanceWindowView'
1772
+ - type: 'null'
1773
+ wasLocked: { type: boolean }
1774
+
1495
1775
  # ────────── Catalog V2 (DTO = SSOT: packages/nest/src/catalog/dto/*) ──────────
1496
1776
  CatalogPlanCreate:
1497
1777
  type: object
@@ -1915,6 +2195,38 @@ components:
1915
2195
  promoRedemption:
1916
2196
  $ref: '#/components/schemas/PromoRedemptionDto'
1917
2197
 
2198
+ AdminSubscriberAccount:
2199
+ type: object
2200
+ required: [holder, entries]
2201
+ properties:
2202
+ holder:
2203
+ type: object
2204
+ nullable: true
2205
+ description: Whose account it is; null for a tenant that has no subscriber.
2206
+ required: [id, customerNumber, legalName]
2207
+ properties:
2208
+ id: { type: string }
2209
+ customerNumber: { type: string }
2210
+ legalName: { type: string }
2211
+ entries:
2212
+ type: array
2213
+ description: >-
2214
+ Newest first — by when each charge became due, then by
2215
+ its period, then the plan before its add-ons before its
2216
+ discounts.
2217
+ items:
2218
+ type: object
2219
+ required: [charge, title]
2220
+ properties:
2221
+ charge: { $ref: 'schemas/subscriber-ledger.schema.json' }
2222
+ title:
2223
+ type: string
2224
+ nullable: true
2225
+ description: >-
2226
+ The contract line's title as it was when the
2227
+ contract was concluded; null where that line
2228
+ cannot be read.
2229
+
1918
2230
  # ────────── User ──────────
1919
2231
  AdminUserRow:
1920
2232
  type: object
package/index.cjs CHANGED
@@ -3,19 +3,19 @@ const adminManifestSchema = require('./schemas/admin-manifest.schema.json');
3
3
  const planCatalogSchema = require('./schemas/plan-catalog.schema.json');
4
4
  const promoCodeSchema = require('./schemas/promo-code.schema.json');
5
5
  const auditEventSchema = require('./schemas/audit-event.schema.json');
6
- const tenantLedgerSchema = require('./schemas/tenant-ledger.schema.json');
6
+ const subscriberLedgerSchema = require('./schemas/subscriber-ledger.schema.json');
7
7
 
8
8
  module.exports = {
9
9
  adminManifestSchema,
10
10
  planCatalogSchema,
11
11
  promoCodeSchema,
12
12
  auditEventSchema,
13
- tenantLedgerSchema,
13
+ subscriberLedgerSchema,
14
14
  SCHEMAS: {
15
15
  adminManifest: adminManifestSchema,
16
16
  planCatalog: planCatalogSchema,
17
17
  promoCode: promoCodeSchema,
18
18
  auditEvent: auditEventSchema,
19
- tenantLedger: tenantLedgerSchema,
19
+ subscriberLedger: subscriberLedgerSchema,
20
20
  },
21
21
  };
package/index.d.cts CHANGED
@@ -4,12 +4,12 @@ export declare const adminManifestSchema: Record<string, unknown>;
4
4
  export declare const planCatalogSchema: Record<string, unknown>;
5
5
  export declare const promoCodeSchema: Record<string, unknown>;
6
6
  export declare const auditEventSchema: Record<string, unknown>;
7
- export declare const tenantLedgerSchema: Record<string, unknown>;
7
+ export declare const subscriberLedgerSchema: Record<string, unknown>;
8
8
 
9
9
  export declare const SCHEMAS: {
10
10
  readonly adminManifest: Record<string, unknown>;
11
11
  readonly planCatalog: Record<string, unknown>;
12
12
  readonly promoCode: Record<string, unknown>;
13
13
  readonly auditEvent: Record<string, unknown>;
14
- readonly tenantLedger: Record<string, unknown>;
14
+ readonly subscriberLedger: Record<string, unknown>;
15
15
  };
package/index.d.ts CHANGED
@@ -5,12 +5,12 @@ export declare const adminManifestSchema: Record<string, unknown>;
5
5
  export declare const planCatalogSchema: Record<string, unknown>;
6
6
  export declare const promoCodeSchema: Record<string, unknown>;
7
7
  export declare const auditEventSchema: Record<string, unknown>;
8
- export declare const tenantLedgerSchema: Record<string, unknown>;
8
+ export declare const subscriberLedgerSchema: Record<string, unknown>;
9
9
 
10
10
  export declare const SCHEMAS: {
11
11
  readonly adminManifest: Record<string, unknown>;
12
12
  readonly planCatalog: Record<string, unknown>;
13
13
  readonly promoCode: Record<string, unknown>;
14
14
  readonly auditEvent: Record<string, unknown>;
15
- readonly tenantLedger: Record<string, unknown>;
15
+ readonly subscriberLedger: Record<string, unknown>;
16
16
  };
package/index.js CHANGED
@@ -6,14 +6,14 @@ import adminManifestSchema from './schemas/admin-manifest.schema.json' with { ty
6
6
  import planCatalogSchema from './schemas/plan-catalog.schema.json' with { type: 'json' };
7
7
  import promoCodeSchema from './schemas/promo-code.schema.json' with { type: 'json' };
8
8
  import auditEventSchema from './schemas/audit-event.schema.json' with { type: 'json' };
9
- import tenantLedgerSchema from './schemas/tenant-ledger.schema.json' with { type: 'json' };
9
+ import subscriberLedgerSchema from './schemas/subscriber-ledger.schema.json' with { type: 'json' };
10
10
 
11
11
  export {
12
12
  adminManifestSchema,
13
13
  planCatalogSchema,
14
14
  promoCodeSchema,
15
15
  auditEventSchema,
16
- tenantLedgerSchema,
16
+ subscriberLedgerSchema,
17
17
  };
18
18
 
19
19
  export const SCHEMAS = {
@@ -21,5 +21,5 @@ export const SCHEMAS = {
21
21
  planCatalog: planCatalogSchema,
22
22
  promoCode: promoCodeSchema,
23
23
  auditEvent: auditEventSchema,
24
- tenantLedger: tenantLedgerSchema,
24
+ subscriberLedger: subscriberLedgerSchema,
25
25
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.21",
3
+ "version": "1.0.0-rc.23",
4
4
  "description": "Language-neutral spec of the SaaS platform: JSON Schemas, OpenAPI contract, Prisma fragments, acceptance scenarios.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -64,7 +64,8 @@ model SubscriptionContract {
64
64
  promoCodeSnapshots Json @default("[]")
65
65
  termsSnapshot Json?
66
66
 
67
- lineItems ContractLineItem[]
67
+ lineItems ContractLineItem[]
68
+ ledgerEntries SubscriberLedgerEntry[] // see 15-subscriber-ledger.prisma
68
69
 
69
70
  createdAt DateTime @default(now())
70
71
  updatedAt DateTime @updatedAt
@@ -121,7 +122,8 @@ model ContractLineItem {
121
122
 
122
123
  createdAt DateTime @default(now())
123
124
 
124
- contract SubscriptionContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
125
+ contract SubscriptionContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
126
+ ledgerEntries SubscriberLedgerEntry[] // see 15-subscriber-ledger.prisma
125
127
 
126
128
  @@index([contractId, kind])
127
129
  @@index([sourceVersionId])
@@ -52,6 +52,7 @@ model Subscriber {
52
52
  contracts SubscriptionContract[]
53
53
  paymentMethods SubscriberPaymentMethod[] // see 14-payments.prisma
54
54
  paymentMethodSetups SubscriberPaymentMethodSetup[]
55
+ ledgerEntries SubscriberLedgerEntry[] // see 15-subscriber-ledger.prisma
55
56
 
56
57
  @@map("subscribers")
57
58
  }
@@ -0,0 +1,65 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: SubscriberLedgerEntry
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // The subscriber's account: one row per charge that became due, one per
8
+ // contract line and period. The platform derives each charge from the contract
9
+ // in force, the billing windows and the bookings, and writes it once — the
10
+ // unique index below is what makes a second derivation of the same charge a
11
+ // no-op, however many run at the same time. A row is never updated or deleted:
12
+ // a mistake is answered with a counter-entry.
13
+ //
14
+ // A charge is net. The tax is the invoice's, decided by the tax adapter when
15
+ // the charge is invoiced (ADR 0013), so this table records the amount, its
16
+ // currency and the period, and nothing that a later invoice would have to
17
+ // agree with to the cent.
18
+ //
19
+ // The account belongs to the subscriber, not to the tenant, and outlives it
20
+ // (ADR 0012): `tenantId` and `subscriptionId` are kept as values, and the keys
21
+ // that point anywhere point at the subscriber and the contract line, which are
22
+ // kept as long as the documents that need them.
23
+ //
24
+ // `origin` and `source` are text holding a closed set, checked when a row is
25
+ // read (`toSubscriberChargeRecord` in @saasicat/core):
26
+ // origin: activation | renewal | planChange | bundleBooking | credit | correction
27
+ // source: plan | bundle | discount
28
+ //
29
+ // Optional: adopt it where `tenantBilling.chargeJournal` is configured, which
30
+ // needs frozen contracts (`contractFreeze`).
31
+ //
32
+ // Contract: @saasicat/core (SubscriberLedgerRepository).
33
+
34
+ model SubscriberLedgerEntry {
35
+ id String @id @default(uuid())
36
+ subscriberId String
37
+ tenantId String
38
+ subscriptionId String
39
+ contractId String
40
+ contractLineItemId String
41
+
42
+ origin String
43
+ source String
44
+ // The subscription id for the plan, the booking id for an add-on, the
45
+ // discount's key — stable across the contracts that replace one another.
46
+ sourceRef String
47
+
48
+ periodStart DateTime
49
+ periodEnd DateTime
50
+
51
+ currency String
52
+ amountNet Decimal @db.Decimal(10, 2)
53
+
54
+ bookedAt DateTime
55
+ createdAt DateTime @default(now())
56
+
57
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
58
+ contract SubscriptionContract @relation(fields: [contractId], references: [id], onDelete: Restrict)
59
+ contractLineItem ContractLineItem @relation(fields: [contractLineItemId], references: [id], onDelete: Restrict)
60
+
61
+ @@unique([subscriptionId, source, sourceRef, periodStart, origin])
62
+ @@index([subscriberId, bookedAt])
63
+ @@index([contractLineItemId])
64
+ @@map("subscriber_ledger_entries")
65
+ }
@@ -0,0 +1,47 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: MaintenanceWindow
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // One row per maintenance window: announced to the tenants ahead of time,
8
+ // locked while a deploy runs a migration that must not race with requests, and
9
+ // ended when the operator says so. A window can also be locked at once without
10
+ // having been announced, which leaves `startsAt` null.
11
+ //
12
+ // While a window is locked, every running version of the application reads this
13
+ // table on its requests — the one being replaced as well as the one replacing
14
+ // it. Its shape is therefore part of the contract between two releases: it
15
+ // gains columns, and never in a deploy the lock protects.
16
+ //
17
+ // At most ONE window is open (not yet ended) at a time. Prisma cannot express a
18
+ // partial unique index; the one that holds it is in
19
+ // sql/constraints.postgres.sql, and it is what makes two operators locking at
20
+ // the same moment land one window rather than two.
21
+ //
22
+ // Optional: adopt it where `maintenance` is turned on in
23
+ // `SaaSiCatModule.forRoot`, which refuses to start without a place to keep
24
+ // windows.
25
+
26
+ model MaintenanceWindow {
27
+ id String @id @default(uuid())
28
+ // What the tenants were told: the announced start and the expected end.
29
+ // Neither starts or ends the lock — that is the operator's command.
30
+ startsAt DateTime?
31
+ endsAt DateTime?
32
+ // The operator's words to the tenants, shown as written.
33
+ message String?
34
+ // When the window was recorded — announced, or locked at once — and by whom
35
+ // (an actor tag, as the audit log writes it).
36
+ createdAt DateTime @default(now())
37
+ createdBy String
38
+ // Null while the window is only announced.
39
+ lockedAt DateTime?
40
+ lockedBy String?
41
+ // Set when the window is over: unlocked, or cancelled before it locked.
42
+ endedAt DateTime?
43
+ endedBy String?
44
+
45
+ @@index([createdAt])
46
+ @@map("maintenance_windows")
47
+ }
@@ -44,6 +44,8 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
44
44
  | [`12-applied-settings.prisma`](12-applied-settings.prisma) | `AppliedSettings`, `SettingsChange` |
45
45
  | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection` |
46
46
  | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
47
+ | [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma) | `SubscriberLedgerEntry` |
48
+ | [`16-maintenance-window.prisma`](16-maintenance-window.prisma) | `MaintenanceWindow` |
47
49
 
48
50
  ## How the consumer uses the fragments
49
51
 
@@ -141,7 +141,8 @@
141
141
  "marketingCatalog": { "$ref": "#/$defs/StandardPageDef" },
142
142
  "platformEmail": { "$ref": "#/$defs/StandardPageDef" },
143
143
  "platformEmailHistory": { "$ref": "#/$defs/StandardPageDef" },
144
- "settings": { "$ref": "#/$defs/StandardPageDef" }
144
+ "settings": { "$ref": "#/$defs/StandardPageDef" },
145
+ "maintenance": { "$ref": "#/$defs/StandardPageDef" }
145
146
  }
146
147
  },
147
148
  "projectPages": {
@@ -0,0 +1,100 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://saasicat.dev/schemas/subscriber-ledger.schema.json",
4
+ "title": "SubscriberCharge",
5
+ "description": "One charge in a subscriber's account: an amount that became due for a period, derived from a line of the contract in force. The journal is append-only — a charge is never updated, and a mistake is answered with a counter-entry rather than an edit, so the account says what happened rather than what somebody thinks today. A charge is net: the tax is decided by the tax adapter when the charge is invoiced, from the subscriber's origin on that day, and computed once per rate on the invoice.",
6
+ "type": "object",
7
+ "required": [
8
+ "id",
9
+ "subscriberId",
10
+ "tenantId",
11
+ "subscriptionId",
12
+ "contractId",
13
+ "contractLineItemId",
14
+ "origin",
15
+ "source",
16
+ "sourceRef",
17
+ "periodStart",
18
+ "periodEnd",
19
+ "currency",
20
+ "amountNet",
21
+ "bookedAt",
22
+ "createdAt"
23
+ ],
24
+ "additionalProperties": false,
25
+ "properties": {
26
+ "id": {
27
+ "type": "string",
28
+ "format": "uuid",
29
+ "description": "The charge's own identifier. Never shown to a customer as an invoice number: invoice numbering is sequential, gapless and legally constrained per country, and an identifier somebody has already seen on a screen cannot become one later without confusion."
30
+ },
31
+ "subscriberId": {
32
+ "type": "string",
33
+ "description": "The party the contract is concluded with, whose account this is. It outlives the tenant."
34
+ },
35
+ "tenantId": {
36
+ "type": "string",
37
+ "description": "The tenant the subscription belonged to when the charge arose."
38
+ },
39
+ "subscriptionId": {
40
+ "type": "string",
41
+ "description": "The subscription the charge arose on. Part of the natural key."
42
+ },
43
+ "contractId": {
44
+ "type": "string",
45
+ "description": "The contract whose line the charge realises."
46
+ },
47
+ "contractLineItemId": {
48
+ "type": "string",
49
+ "description": "The contract line the charge came from, so that every invoice line can be traced back to what was agreed."
50
+ },
51
+ "origin": { "$ref": "#/$defs/SubscriberChargeOrigin" },
52
+ "source": { "$ref": "#/$defs/SubscriberChargeSource" },
53
+ "sourceRef": {
54
+ "type": "string",
55
+ "minLength": 1,
56
+ "description": "Which thing of its source the charge is for, stable across contracts: the subscription id for the plan, the booking id for an add-on, the discount's key for a discount. Part of the natural key (subscriptionId, source, sourceRef, periodStart, origin), which is unique — a charge derived twice is written once."
57
+ },
58
+ "periodStart": {
59
+ "type": "string",
60
+ "format": "date-time",
61
+ "description": "The start of the period the charge belongs to — the grouping key for an invoice."
62
+ },
63
+ "periodEnd": { "type": "string", "format": "date-time" },
64
+ "currency": {
65
+ "type": "string",
66
+ "pattern": "^[A-Z]{3}$",
67
+ "description": "ISO 4217, as the contract line records it — recorded per charge so that a charge written in 2026 still means what it meant after the configured currency changes."
68
+ },
69
+ "amountNet": {
70
+ "type": "number",
71
+ "description": "Net, rounded to the currency's decimal places once, when the charge is written; the written figure is the truth from then on. Negative for a discount."
72
+ },
73
+ "bookedAt": {
74
+ "type": "string",
75
+ "format": "date-time",
76
+ "description": "When the amount became due. The account is ordered by this, not by when the row was written."
77
+ },
78
+ "createdAt": { "type": "string", "format": "date-time" }
79
+ },
80
+
81
+ "$defs": {
82
+ "SubscriberChargeOrigin": {
83
+ "type": "string",
84
+ "enum": [
85
+ "activation",
86
+ "renewal",
87
+ "planChange",
88
+ "bundleBooking",
89
+ "credit",
90
+ "correction"
91
+ ],
92
+ "description": "What made the charge arise. activation: the first period of a subscription. renewal: a later period. planChange: what an immediate plan change adds. bundleBooking: the first, short period of an add-on. credit: a decision that reduces what is owed. correction: a counter-entry reversing an earlier charge, which is how the append-only journal answers a mistake."
93
+ },
94
+ "SubscriberChargeSource": {
95
+ "type": "string",
96
+ "enum": ["plan", "bundle", "discount"],
97
+ "description": "Which kind of contract line the charge realises."
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,101 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a subscriber's account records the charges its contracts give.
3
+ -- =============================================================================
4
+ --
5
+ -- The journal of charges: one row per contract line and period, derived from
6
+ -- the contract in force, the billing windows and the bookings, and written
7
+ -- once (ADR 0012, #276 step 4). Needed where `tenantBilling.chargeJournal` is
8
+ -- configured. Run it BEFORE `db push`, and after
9
+ -- `1.0-a-contract-names-its-subscriber.postgres.sql`, whose `subscribers`
10
+ -- table it points at:
11
+ --
12
+ -- psql "$DATABASE_URL" -f 1.0-a-subscriber-account-records-its-charges.postgres.sql
13
+ --
14
+ -- What it touches, in one transaction:
15
+ --
16
+ -- `subscriber_ledger_entries` — created with its indexes, among them the
17
+ -- unique index on (subscriptionId, source, sourceRef, periodStart, origin)
18
+ -- that makes a charge derived twice a charge written once, and its foreign
19
+ -- keys to `subscribers`, `subscription_contracts` and `contract_line_items`.
20
+ -- Skipped, with a notice, where one of those three tables is missing.
21
+ --
22
+ -- Nothing is backfilled: the first time the platform brings an account up to
23
+ -- date it charges the period each subscription is in, and nothing before it.
24
+ --
25
+ -- Safe to run again: the table and every index are created only where they are
26
+ -- missing, and each foreign key only where its name is not taken. On a
27
+ -- database created from `reference-schema.postgres.sql` the whole file does
28
+ -- nothing at all.
29
+
30
+ BEGIN;
31
+
32
+ DO $$
33
+ BEGIN
34
+ IF to_regclass('subscribers') IS NULL
35
+ OR to_regclass('subscription_contracts') IS NULL
36
+ OR to_regclass('contract_line_items') IS NULL THEN
37
+ RAISE NOTICE 'subscribers, subscription_contracts or contract_line_items is not present — subscriber_ledger_entries is not created; the journal needs contracts and the subscribers they are concluded with.';
38
+ RETURN;
39
+ END IF;
40
+
41
+ CREATE TABLE IF NOT EXISTS "subscriber_ledger_entries" (
42
+ "id" TEXT NOT NULL,
43
+ "subscriberId" TEXT NOT NULL,
44
+ "tenantId" TEXT NOT NULL,
45
+ "subscriptionId" TEXT NOT NULL,
46
+ "contractId" TEXT NOT NULL,
47
+ "contractLineItemId" TEXT NOT NULL,
48
+ "origin" TEXT NOT NULL,
49
+ "source" TEXT NOT NULL,
50
+ "sourceRef" TEXT NOT NULL,
51
+ "periodStart" TIMESTAMP(3) NOT NULL,
52
+ "periodEnd" TIMESTAMP(3) NOT NULL,
53
+ "currency" TEXT NOT NULL,
54
+ "amountNet" DECIMAL(10,2) NOT NULL,
55
+ "bookedAt" TIMESTAMP(3) NOT NULL,
56
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
57
+ CONSTRAINT "subscriber_ledger_entries_pkey" PRIMARY KEY ("id")
58
+ );
59
+ CREATE INDEX IF NOT EXISTS "subscriber_ledger_entries_subscriberId_bookedAt_idx"
60
+ ON "subscriber_ledger_entries"("subscriberId", "bookedAt");
61
+ CREATE INDEX IF NOT EXISTS "subscriber_ledger_entries_contractLineItemId_idx"
62
+ ON "subscriber_ledger_entries"("contractLineItemId");
63
+ -- The name is the one Prisma derives and cuts at 63 characters, so that a
64
+ -- later `db push` finds it rather than creating a second index.
65
+ CREATE UNIQUE INDEX IF NOT EXISTS "subscriber_ledger_entries_subscriptionId_source_sourceRef_p_key"
66
+ ON "subscriber_ledger_entries"("subscriptionId", "source", "sourceRef", "periodStart", "origin");
67
+
68
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`.
69
+ IF NOT EXISTS (
70
+ SELECT 1 FROM pg_constraint
71
+ WHERE conrelid = to_regclass('subscriber_ledger_entries')
72
+ AND conname = 'subscriber_ledger_entries_subscriberId_fkey'
73
+ ) THEN
74
+ ALTER TABLE "subscriber_ledger_entries"
75
+ ADD CONSTRAINT "subscriber_ledger_entries_subscriberId_fkey"
76
+ FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
77
+ ON DELETE RESTRICT ON UPDATE CASCADE;
78
+ END IF;
79
+ IF NOT EXISTS (
80
+ SELECT 1 FROM pg_constraint
81
+ WHERE conrelid = to_regclass('subscriber_ledger_entries')
82
+ AND conname = 'subscriber_ledger_entries_contractId_fkey'
83
+ ) THEN
84
+ ALTER TABLE "subscriber_ledger_entries"
85
+ ADD CONSTRAINT "subscriber_ledger_entries_contractId_fkey"
86
+ FOREIGN KEY ("contractId") REFERENCES "subscription_contracts"("id")
87
+ ON DELETE RESTRICT ON UPDATE CASCADE;
88
+ END IF;
89
+ IF NOT EXISTS (
90
+ SELECT 1 FROM pg_constraint
91
+ WHERE conrelid = to_regclass('subscriber_ledger_entries')
92
+ AND conname = 'subscriber_ledger_entries_contractLineItemId_fkey'
93
+ ) THEN
94
+ ALTER TABLE "subscriber_ledger_entries"
95
+ ADD CONSTRAINT "subscriber_ledger_entries_contractLineItemId_fkey"
96
+ FOREIGN KEY ("contractLineItemId") REFERENCES "contract_line_items"("id")
97
+ ON DELETE RESTRICT ON UPDATE CASCADE;
98
+ END IF;
99
+ END $$;
100
+
101
+ COMMIT;
@@ -0,0 +1,51 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — maintenance windows are kept.
3
+ -- =============================================================================
4
+ --
5
+ -- One table, additive: `maintenance_windows` holds one row per maintenance
6
+ -- window — announced to the tenants, locked while a deploy runs, ended when the
7
+ -- operator unlocks. The platform reads it on every request while it is turned
8
+ -- on (`maintenance` in `SaaSiCatModule.forRoot`), through a cache of a few
9
+ -- seconds.
10
+ --
11
+ -- Run it the same way as the other files in this directory, before `db push`
12
+ -- where you use one:
13
+ --
14
+ -- psql "$DATABASE_URL" -f 1.0-maintenance-windows-are-kept.postgres.sql
15
+ --
16
+ -- Apply it in an ordinary deploy, BEFORE the first deploy you lock: the lock
17
+ -- protects a deploy only when the version already running reads it, so the
18
+ -- table has to be there, and that version with it, before it is needed.
19
+ --
20
+ -- The index on a constant is what holds an installation to one open window:
21
+ -- every open row carries the same key, so a second one collides, and a window
22
+ -- that ended leaves the index.
23
+ --
24
+ -- Safe to run again: the table and both indexes are created only where they are
25
+ -- missing. On a database created from `reference-schema.postgres.sql` the whole
26
+ -- file does nothing at all.
27
+
28
+ BEGIN;
29
+
30
+ CREATE TABLE IF NOT EXISTS "maintenance_windows" (
31
+ "id" TEXT NOT NULL,
32
+ "startsAt" TIMESTAMP(3),
33
+ "endsAt" TIMESTAMP(3),
34
+ "message" TEXT,
35
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+ "createdBy" TEXT NOT NULL,
37
+ "lockedAt" TIMESTAMP(3),
38
+ "lockedBy" TEXT,
39
+ "endedAt" TIMESTAMP(3),
40
+ "endedBy" TEXT,
41
+
42
+ CONSTRAINT "maintenance_windows_pkey" PRIMARY KEY ("id")
43
+ );
44
+
45
+ CREATE INDEX IF NOT EXISTS "maintenance_windows_createdAt_idx"
46
+ ON "maintenance_windows"("createdAt");
47
+
48
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
49
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
50
+
51
+ COMMIT;
@@ -47,6 +47,15 @@ ALTER TABLE applied_settings
47
47
  ALTER TABLE applied_settings
48
48
  ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
49
49
 
50
+ -- At most ONE maintenance window is open — announced or locked, not yet ended.
51
+ --
52
+ -- The index is on a constant, so every open row carries the same key and a
53
+ -- second one collides. A window that ended leaves the index and makes room for
54
+ -- the next. Two operators locking at the same moment therefore land one window,
55
+ -- because the second insert meets the first and is told a window is open.
56
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
57
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
58
+
50
59
  -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
51
60
  -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
52
61
  -- tenants a subscriber had before stay in its history. Two partial unique
@@ -17,6 +17,8 @@
17
17
  -- prisma-fragments/12-applied-settings.prisma
18
18
  -- prisma-fragments/13-subscriber.prisma
19
19
  -- prisma-fragments/14-payments.prisma
20
+ -- prisma-fragments/15-subscriber-ledger.prisma
21
+ -- prisma-fragments/16-maintenance-window.prisma
20
22
  -- plus the normative constraints from sql/constraints.postgres.sql.
21
23
  -- Do not edit by hand — change the fragments/constraints and regenerate.
22
24
 
@@ -684,6 +686,43 @@ CREATE TABLE "PaymentEventLog" (
684
686
  CONSTRAINT "PaymentEventLog_pkey" PRIMARY KEY ("id")
685
687
  );
686
688
 
689
+ -- CreateTable
690
+ CREATE TABLE "subscriber_ledger_entries" (
691
+ "id" TEXT NOT NULL,
692
+ "subscriberId" TEXT NOT NULL,
693
+ "tenantId" TEXT NOT NULL,
694
+ "subscriptionId" TEXT NOT NULL,
695
+ "contractId" TEXT NOT NULL,
696
+ "contractLineItemId" TEXT NOT NULL,
697
+ "origin" TEXT NOT NULL,
698
+ "source" TEXT NOT NULL,
699
+ "sourceRef" TEXT NOT NULL,
700
+ "periodStart" TIMESTAMP(3) NOT NULL,
701
+ "periodEnd" TIMESTAMP(3) NOT NULL,
702
+ "currency" TEXT NOT NULL,
703
+ "amountNet" DECIMAL(10,2) NOT NULL,
704
+ "bookedAt" TIMESTAMP(3) NOT NULL,
705
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
706
+
707
+ CONSTRAINT "subscriber_ledger_entries_pkey" PRIMARY KEY ("id")
708
+ );
709
+
710
+ -- CreateTable
711
+ CREATE TABLE "maintenance_windows" (
712
+ "id" TEXT NOT NULL,
713
+ "startsAt" TIMESTAMP(3),
714
+ "endsAt" TIMESTAMP(3),
715
+ "message" TEXT,
716
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
717
+ "createdBy" TEXT NOT NULL,
718
+ "lockedAt" TIMESTAMP(3),
719
+ "lockedBy" TEXT,
720
+ "endedAt" TIMESTAMP(3),
721
+ "endedBy" TEXT,
722
+
723
+ CONSTRAINT "maintenance_windows_pkey" PRIMARY KEY ("id")
724
+ );
725
+
687
726
  -- CreateIndex
688
727
  CREATE UNIQUE INDEX "subscriptions_tenantId_key" ON "subscriptions"("tenantId");
689
728
 
@@ -909,6 +948,18 @@ CREATE INDEX "PaymentEventLog_status_processedAt_idx" ON "PaymentEventLog"("stat
909
948
  -- CreateIndex
910
949
  CREATE UNIQUE INDEX "PaymentEventLog_gatewayAccount_eventId_key" ON "PaymentEventLog"("gatewayAccount", "eventId");
911
950
 
951
+ -- CreateIndex
952
+ CREATE INDEX "subscriber_ledger_entries_subscriberId_bookedAt_idx" ON "subscriber_ledger_entries"("subscriberId", "bookedAt");
953
+
954
+ -- CreateIndex
955
+ CREATE INDEX "subscriber_ledger_entries_contractLineItemId_idx" ON "subscriber_ledger_entries"("contractLineItemId");
956
+
957
+ -- CreateIndex
958
+ CREATE UNIQUE INDEX "subscriber_ledger_entries_subscriptionId_source_sourceRef_p_key" ON "subscriber_ledger_entries"("subscriptionId", "source", "sourceRef", "periodStart", "origin");
959
+
960
+ -- CreateIndex
961
+ CREATE INDEX "maintenance_windows_createdAt_idx" ON "maintenance_windows"("createdAt");
962
+
912
963
  -- AddForeignKey
913
964
  ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planVersionId_fkey" FOREIGN KEY ("planVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
914
965
 
@@ -960,6 +1011,15 @@ ALTER TABLE "subscriber_payment_methods" ADD CONSTRAINT "subscriber_payment_meth
960
1011
  -- AddForeignKey
961
1012
  ALTER TABLE "subscriber_payment_method_setups" ADD CONSTRAINT "subscriber_payment_method_setups_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
962
1013
 
1014
+ -- AddForeignKey
1015
+ ALTER TABLE "subscriber_ledger_entries" ADD CONSTRAINT "subscriber_ledger_entries_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
1016
+
1017
+ -- AddForeignKey
1018
+ ALTER TABLE "subscriber_ledger_entries" ADD CONSTRAINT "subscriber_ledger_entries_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "subscription_contracts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
1019
+
1020
+ -- AddForeignKey
1021
+ ALTER TABLE "subscriber_ledger_entries" ADD CONSTRAINT "subscriber_ledger_entries_contractLineItemId_fkey" FOREIGN KEY ("contractLineItemId") REFERENCES "contract_line_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
1022
+
963
1023
  -- =============================================================================
964
1024
  -- SaaSiCat — normative PostgreSQL constraints the Prisma DSL cannot express.
965
1025
  -- =============================================================================
@@ -1009,6 +1069,15 @@ ALTER TABLE applied_settings
1009
1069
  ALTER TABLE applied_settings
1010
1070
  ADD CONSTRAINT applied_settings_is_a_singleton CHECK ("id" = 'installation');
1011
1071
 
1072
+ -- At most ONE maintenance window is open — announced or locked, not yet ended.
1073
+ --
1074
+ -- The index is on a constant, so every open row carries the same key and a
1075
+ -- second one collides. A window that ended leaves the index and makes room for
1076
+ -- the next. Two operators locking at the same moment therefore land one window,
1077
+ -- because the second insert meets the first and is told a window is open.
1078
+ CREATE UNIQUE INDEX IF NOT EXISTS maintenance_windows_one_open
1079
+ ON maintenance_windows ((true)) WHERE "endedAt" IS NULL;
1080
+
1012
1081
  -- A subscriber is live for at most ONE tenant, and a tenant has at most ONE
1013
1082
  -- live subscriber. A link that ended keeps its row with `unlinkedAt` set, so the
1014
1083
  -- tenants a subscriber had before stay in its history. Two partial unique
@@ -1,236 +0,0 @@
1
- {
2
- "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "$id": "https://saasicat.dev/schemas/tenant-ledger.schema.json",
4
- "title": "TenantLedgerEntry",
5
- "description": "One entry in a tenant's account. Two kinds share one journal: a charge is what became due, a payment is what settled part of it. The journal is append-only — an entry is never updated, and a mistake is answered with a counter-entry rather than an edit, so the account says what happened rather than what somebody thinks today.",
6
- "oneOf": [{ "$ref": "#/$defs/LedgerCharge" }, { "$ref": "#/$defs/LedgerPayment" }],
7
-
8
- "$defs": {
9
- "LedgerEntryKind": {
10
- "type": "string",
11
- "enum": ["charge", "payment"],
12
- "description": "charge: an amount became due. payment: an amount settled."
13
- },
14
-
15
- "ChargeOrigin": {
16
- "type": "string",
17
- "enum": [
18
- "activation",
19
- "renewal",
20
- "planChange",
21
- "bundleBooking",
22
- "credit",
23
- "correction"
24
- ],
25
- "description": "What made the charge arise. activation: the first period of a subscription. renewal: a period renewing. planChange: the prorated difference of an immediate plan change. bundleBooking: an add-on booked. credit: a decision that reduces what is owed. correction: a counter-entry reversing an earlier one, which is how the append-only journal answers a mistake."
26
- },
27
-
28
- "Currency": {
29
- "type": "string",
30
- "pattern": "^[A-Z]{3}$",
31
- "description": "ISO 4217. Recorded per entry even though an installation configures one at a time, so that a row written in 2026 still means what it meant after the configured currency changes."
32
- },
33
-
34
- "LedgerCharge": {
35
- "type": "object",
36
- "title": "LedgerCharge",
37
- "description": "An amount that became due. It names the period it belongs to, so the charges that make up one invoice can be found later, and it names the agreement it came from, so it can be walked back to what the tenant agreed to.",
38
- "required": [
39
- "id",
40
- "tenantId",
41
- "kind",
42
- "subscriptionId",
43
- "origin",
44
- "originRef",
45
- "periodStart",
46
- "periodEnd",
47
- "currency",
48
- "amountNet",
49
- "taxRate",
50
- "taxAmount",
51
- "amountGross",
52
- "bookedAt",
53
- "createdAt"
54
- ],
55
- "additionalProperties": false,
56
- "properties": {
57
- "id": {
58
- "type": "string",
59
- "format": "uuid",
60
- "description": "The entry's own identifier. Never shown to a customer as an invoice number: invoice numbering is sequential, gapless and legally constrained per country, and an identifier somebody has already seen on a screen cannot become one later without confusion."
61
- },
62
- "tenantId": { "type": "string" },
63
- "kind": { "const": "charge" },
64
- "subscriptionId": {
65
- "type": "string",
66
- "description": "The subscription the charge arose on. Part of the natural key below."
67
- },
68
- "origin": { "$ref": "#/$defs/ChargeOrigin" },
69
- "originRef": {
70
- "type": "string",
71
- "minLength": 1,
72
- "description": "Which thing within the origin the charge is for, so that two charges of the same origin in the same period stay apart. activation and renewal: the subscription id. planChange: the id of the contract the change concluded, which differs for every change. bundleBooking: the booking id. credit and correction: the entry they answer. Never empty — the natural key (subscriptionId, origin, originRef, periodStart) is unique, and a NULL would not collide with itself, which is the one thing the key exists to prevent."
73
- },
74
- "periodStart": {
75
- "type": "string",
76
- "format": "date-time",
77
- "description": "The billing period the charge belongs to. It is the grouping key for a later invoice: a set of individually booked amounts with nothing tying them to a period leaves 'which charges go on one invoice' to guesswork."
78
- },
79
- "periodEnd": { "type": "string", "format": "date-time" },
80
- "contractId": {
81
- "type": ["string", "null"],
82
- "description": "The frozen agreement the charge realises. Null where none was frozen — a subscription older than the contract model, or a credit that answers no line."
83
- },
84
- "contractLineItemId": {
85
- "type": ["string", "null"],
86
- "description": "The contract line the charge came from. Null under the same conditions as contractId."
87
- },
88
- "currency": { "$ref": "#/$defs/Currency" },
89
- "amountNet": {
90
- "type": "number",
91
- "description": "Held to the currency's decimal places. Negative on a credit or a correction."
92
- },
93
- "taxRate": {
94
- "type": "number",
95
- "minimum": 0,
96
- "maximum": 100,
97
- "description": "The rate in percent, recorded rather than left to live in the ratio between net and gross. A ratio cannot be reproduced for a rounded gross, cannot express an exempt or reverse-charge line, and does not survive a rate change."
98
- },
99
- "taxAmount": {
100
- "type": "number",
101
- "description": "The tax contained in the charge. It closes the gap between amountNet and amountGross exactly, so the entry cannot disagree with itself."
102
- },
103
- "amountGross": { "type": "number" },
104
- "bookedAt": {
105
- "type": "string",
106
- "format": "date-time",
107
- "description": "When the amount became due. The account is ordered by this, not by when the row was written."
108
- },
109
- "createdAt": { "type": "string", "format": "date-time" }
110
- }
111
- },
112
-
113
- "LedgerPayment": {
114
- "type": "object",
115
- "title": "LedgerPayment",
116
- "description": "An amount that settled. What talks to a bank, a card processor or an invoice run is a consumer's adapter behind a port; this records only that the money arrived and which external reference it arrived under. It carries no tax split: a payment is a movement of money, and the net, the rate and the tax belong to the charges it settles — asking a payment for them would make an integrator write a number nobody knows, which the account would then read as a fact.",
117
- "required": [
118
- "id",
119
- "tenantId",
120
- "kind",
121
- "externalReference",
122
- "currency",
123
- "amountGross",
124
- "bookedAt",
125
- "createdAt"
126
- ],
127
- "additionalProperties": false,
128
- "properties": {
129
- "id": { "type": "string", "format": "uuid" },
130
- "tenantId": { "type": "string" },
131
- "kind": { "const": "payment" },
132
- "externalReference": {
133
- "type": "string",
134
- "minLength": 1,
135
- "description": "The payment provider's own identifier for the settlement. It is what makes a payment reconcilable against the provider, and what a second delivery of the same provider event is recognised by."
136
- },
137
- "settlesEntryId": {
138
- "type": ["string", "null"],
139
- "description": "The charge this payment settles, where the provider says which. Null for a payment on account, which reduces the balance without naming a charge."
140
- },
141
- "currency": { "$ref": "#/$defs/Currency" },
142
- "amountGross": {
143
- "type": "number",
144
- "description": "What arrived. Negative on a reversal, which is a movement the other way rather than an edit of the payment it answers."
145
- },
146
- "bookedAt": {
147
- "type": "string",
148
- "format": "date-time",
149
- "description": "The value date — when the money settled, which is not when the row was written."
150
- },
151
- "createdAt": { "type": "string", "format": "date-time" }
152
- }
153
- },
154
-
155
- "TenantAccountOpenItem": {
156
- "type": "object",
157
- "title": "TenantAccountOpenItem",
158
- "description": "A charge that is not yet settled in full.",
159
- "required": [
160
- "entryId",
161
- "origin",
162
- "periodStart",
163
- "periodEnd",
164
- "currency",
165
- "amountGross",
166
- "settledGross",
167
- "openGross",
168
- "dueAt"
169
- ],
170
- "additionalProperties": false,
171
- "properties": {
172
- "entryId": { "type": "string", "format": "uuid" },
173
- "origin": { "$ref": "#/$defs/ChargeOrigin" },
174
- "periodStart": { "type": "string", "format": "date-time" },
175
- "periodEnd": { "type": "string", "format": "date-time" },
176
- "currency": { "$ref": "#/$defs/Currency" },
177
- "amountGross": { "type": "number" },
178
- "settledGross": {
179
- "type": "number",
180
- "description": "What payments naming this charge have settled so far."
181
- },
182
- "openGross": {
183
- "type": "number",
184
- "description": "amountGross minus settledGross."
185
- },
186
- "dueAt": { "type": "string", "format": "date-time" }
187
- }
188
- },
189
-
190
- "TenantAccount": {
191
- "type": "object",
192
- "title": "TenantAccount",
193
- "description": "What a tenant and an administrator both read: the balance, what is open, and the history. One shape for both, because a balance a tenant is shown and a balance an administrator is shown must not be two answers.",
194
- "required": [
195
- "tenantId",
196
- "currency",
197
- "asOf",
198
- "chargedGross",
199
- "paidGross",
200
- "balanceGross",
201
- "openItems",
202
- "entries"
203
- ],
204
- "additionalProperties": false,
205
- "properties": {
206
- "tenantId": { "type": "string" },
207
- "currency": { "$ref": "#/$defs/Currency" },
208
- "asOf": {
209
- "type": "string",
210
- "format": "date-time",
211
- "description": "A balance is only ever true at a moment, and this is the moment."
212
- },
213
- "chargedGross": { "type": "number" },
214
- "paidGross": { "type": "number" },
215
- "balanceGross": {
216
- "type": "number",
217
- "description": "chargedGross minus paidGross. Positive means the tenant owes; negative means they have paid ahead."
218
- },
219
- "openItems": {
220
- "type": "array",
221
- "items": { "$ref": "#/$defs/TenantAccountOpenItem" }
222
- },
223
- "entries": {
224
- "type": "array",
225
- "description": "The history the read was asked for, newest first. The window is the caller's decision, not a property of the account.",
226
- "items": {
227
- "oneOf": [
228
- { "$ref": "#/$defs/LedgerCharge" },
229
- { "$ref": "#/$defs/LedgerPayment" }
230
- ]
231
- }
232
- }
233
- }
234
- }
235
- }
236
- }