@saasicat/spec 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.
Files changed (33) hide show
  1. package/README.md +30 -0
  2. package/acceptance/README.md +3 -3
  3. package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +6 -6
  4. package/admin-api.openapi.yaml +328 -77
  5. package/cli-conventions.md +19 -19
  6. package/index.cjs +3 -0
  7. package/index.d.cts +3 -1
  8. package/index.d.ts +2 -0
  9. package/index.js +9 -1
  10. package/package.json +4 -2
  11. package/prisma-fragments/01-subscription.prisma +34 -47
  12. package/prisma-fragments/03-plan-versions.prisma +3 -4
  13. package/prisma-fragments/05-bundle.prisma +4 -5
  14. package/prisma-fragments/06-catalog-entries.prisma +24 -24
  15. package/prisma-fragments/07-promotion.prisma +2 -3
  16. package/prisma-fragments/08-subscription-contract.prisma +42 -5
  17. package/prisma-fragments/09-pending-registration.prisma +32 -27
  18. package/prisma-fragments/11-subscription-bundle.prisma +17 -0
  19. package/prisma-fragments/12-applied-settings.prisma +55 -0
  20. package/prisma-fragments/13-subscriber.prisma +94 -0
  21. package/prisma-fragments/14-payments.prisma +100 -0
  22. package/prisma-fragments/README.md +40 -22
  23. package/schemas/admin-manifest.schema.json +9 -2
  24. package/schemas/plan-catalog.schema.json +239 -18
  25. package/schemas/tenant-ledger.schema.json +236 -0
  26. package/sql/1.0-a-contract-names-its-subscriber.postgres.sql +383 -0
  27. package/sql/1.0-a-payment-method-is-a-gateway-reference.postgres.sql +177 -0
  28. package/sql/1.0-a-settings-change-carries-its-order.postgres.sql +73 -0
  29. package/sql/1.0-line-items-record-their-money.postgres.sql +134 -0
  30. package/sql/1.0-remove-project-key.postgres.sql +203 -0
  31. package/sql/1.0-the-applied-settings-are-recorded.postgres.sql +67 -0
  32. package/sql/constraints.postgres.sql +76 -0
  33. package/sql/reference-schema.postgres.sql +299 -77
@@ -0,0 +1,55 @@
1
+ // =============================================================================
2
+ // SaaS-Platform Prisma fragment: the applied settings, and their changes
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // `config/saas.yaml` says what should be true. These two tables say what IS
8
+ // true: the settings the installation applied at its last start, and every
9
+ // start that found them changed. The platform writes both at boot and reads
10
+ // them for the read-only settings screen — and for nothing else. The record is
11
+ // a mirror of the file, never a source: no setting is ever read out of it.
12
+ //
13
+ // One row per installation. An installation serves one application and owns
14
+ // its database (see docs/explanation/data-model.md), so the row's identity is
15
+ // the installation's, and a constant primary key is what expresses that. The
16
+ // `CHECK` in sql/constraints.postgres.sql is what holds it to one row; the
17
+ // default only lands a caller that omits the id on the right one.
18
+
19
+ model AppliedSettings {
20
+ id String @id @default("installation")
21
+ // `sha256-<hex>` over the canonical JSON of `settings`.
22
+ fingerprint String
23
+ // The resolved settings subtree of the catalogue — everything but the
24
+ // plans and features, with environment references already substituted.
25
+ settings Json
26
+ // The absolute path of the file, or the phrase saying they came in as code.
27
+ source String
28
+ // When these values became the running configuration. Left alone by a
29
+ // start that finds them unchanged.
30
+ appliedAt DateTime
31
+
32
+ @@map("applied_settings")
33
+ }
34
+
35
+ model SettingsChange {
36
+ id String @id @default(uuid())
37
+ // The order the changes were recorded in, numbered by the database at the
38
+ // write that recorded each one, and the order the list is read in. Not
39
+ // `noticedAt`: that is the recording start's own clock, and a start delayed
40
+ // between reading it and writing would be listed before a move that landed
41
+ // earlier. Never set by the platform.
42
+ seq Int @unique @default(autoincrement())
43
+ // The start that found the fingerprint moved.
44
+ noticedAt DateTime
45
+ source String
46
+ previous Json
47
+ current Json
48
+ // Null while an operator still owes it a look.
49
+ acknowledgedAt DateTime?
50
+ // An actor tag, as the audit log writes it.
51
+ acknowledgedBy String?
52
+
53
+ @@index([acknowledgedAt, noticedAt])
54
+ @@map("settings_changes")
55
+ }
@@ -0,0 +1,94 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: Subscriber, its tenants, its corrections
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // A tenant is where the application keeps a customer's data. The subscriber is
8
+ // the party the contract is with: a customer number and the master data a
9
+ // contract names. A tenant can be deleted; its subscriber stays for as long as
10
+ // a document that has to be kept belongs to it (ADR 0012). That is why nothing
11
+ // here points at the application's `Tenant` model with a cascade — the link
12
+ // below names the tenant as a value and stays after the tenant row is gone.
13
+ //
14
+ // Every model carries `Subscriber` in its name, so it cannot collide with a
15
+ // `Customer` an application already keeps for the people it sells to.
16
+
17
+ model Subscriber {
18
+ id String @id @default(uuid())
19
+
20
+ // The customer number, counted per installation. The number orders; the
21
+ // prefix is the one `config/saas.yaml` named when the subscriber was
22
+ // created, kept beside it so a later prefix renames nobody. Counting starts
23
+ // at 10001 (sql/constraints.postgres.sql), so numbers have five digits long
24
+ // before they need a sixth.
25
+ customerSequence Int @unique @default(autoincrement())
26
+ customerNumberPrefix String @default("")
27
+
28
+ // The legal identity. Only the name is required until invoicing requires
29
+ // the rest; under a running contract these change only as a correction,
30
+ // recorded in `SubscriberCorrection`.
31
+ legalName String
32
+ vatId String?
33
+ taxNumber String?
34
+
35
+ // Contact details, which change at any time.
36
+ addressLine1 String?
37
+ addressLine2 String?
38
+ postalCode String?
39
+ city String?
40
+ country String? // ISO 3166-1 alpha-2
41
+ invoiceEmail String?
42
+
43
+ // Created by the migration that gave every existing tenant its subscriber,
44
+ // from the application's own tenant record.
45
+ migrated Boolean @default(false)
46
+
47
+ createdAt DateTime @default(now())
48
+ updatedAt DateTime @updatedAt
49
+
50
+ tenants SubscriberTenant[]
51
+ corrections SubscriberCorrection[]
52
+ contracts SubscriptionContract[]
53
+ paymentMethods SubscriberPaymentMethod[] // see 14-payments.prisma
54
+ paymentMethodSetups SubscriberPaymentMethodSetup[]
55
+
56
+ @@map("subscribers")
57
+ }
58
+
59
+ // Which tenant a subscriber is live for, and which it was before. A subscriber
60
+ // has at most one live tenant and a tenant at most one live subscriber — two
61
+ // partial unique indexes in sql/constraints.postgres.sql, which Prisma cannot
62
+ // express. `unlinkedAt` stays null while the link is live.
63
+ model SubscriberTenant {
64
+ id String @id @default(uuid())
65
+ subscriberId String
66
+ tenantId String
67
+ linkedAt DateTime @default(now())
68
+ unlinkedAt DateTime?
69
+
70
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
71
+
72
+ @@index([tenantId])
73
+ @@index([subscriberId])
74
+ @@map("subscriber_tenants")
75
+ }
76
+
77
+ // A correction of a subscriber's legal identity: the values it replaced, the
78
+ // values it wrote, why, and who made it. Only the fields that moved are in the
79
+ // two JSON objects. Written in the same transaction as the change, and never
80
+ // rewritten.
81
+ model SubscriberCorrection {
82
+ id String @id @default(uuid())
83
+ subscriberId String
84
+ previous Json
85
+ corrected Json
86
+ reason String
87
+ correctedBy String
88
+ correctedAt DateTime
89
+
90
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
91
+
92
+ @@index([subscriberId, correctedAt])
93
+ @@map("subscriber_corrections")
94
+ }
@@ -0,0 +1,100 @@
1
+ // =============================================================================
2
+ // SaaS platform Prisma fragment: SubscriberPaymentMethod, its setups, PaymentEventLog
3
+ // =============================================================================
4
+ //
5
+ // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
6
+ //
7
+ // How a subscriber pays, as the payment gateway's reference to it. The card
8
+ // number or the IBAN never reaches this table: the person enters them in the
9
+ // gateway's own form, and what is kept is the gateway's reference and the
10
+ // masked details that tell one payment method from another (ADR 0012).
11
+ //
12
+ // A reference means something only to the gateway account that issued it —
13
+ // the merchant account named in `config/saas.yaml#payments.accounts` — so
14
+ // every row records that account. A subscriber has at most one `ACTIVE`
15
+ // payment method, a partial unique index in sql/constraints.postgres.sql;
16
+ // the one a newer payment method replaced stays as `REPLACED`.
17
+ //
18
+ // Contract: @saasicat/core (SubscriberPaymentMethodRepository, PaymentEventLog).
19
+ // Both are needed wherever payment methods are taken — self-registration or
20
+ // not — because every gateway callback is claimed in `PaymentEventLog`.
21
+
22
+ model SubscriberPaymentMethod {
23
+ id String @id @default(uuid())
24
+ subscriberId String
25
+
26
+ // The account the references below belong to, and its provider when the
27
+ // payment method was confirmed.
28
+ gatewayAccount String
29
+ provider String
30
+ customerRef String
31
+ paymentMethodRef String
32
+
33
+ // What tells the payment method apart, never enough to pay with it.
34
+ type String // card | sepa_debit
35
+ brand String?
36
+ last4 String
37
+ expiryMonth Int?
38
+ expiryYear Int?
39
+ country String? // ISO 3166-1 alpha-2
40
+ bankCode String?
41
+ mandateReference String?
42
+
43
+ status String @default("ACTIVE") // ACTIVE | REPLACED
44
+ confirmedAt DateTime
45
+ replacedAt DateTime?
46
+ createdAt DateTime @default(now())
47
+
48
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
49
+
50
+ @@unique([gatewayAccount, paymentMethodRef])
51
+ @@index([subscriberId, status])
52
+ @@index([gatewayAccount, status])
53
+ @@map("subscriber_payment_methods")
54
+ }
55
+
56
+ // A change of payment method a tenant started: the gateway session opened for
57
+ // the subscriber. A confirmation is recorded only against the setup whose
58
+ // account, session and subscriber it names, and completes it on the same
59
+ // transaction — so a callback naming another subscriber than the session was
60
+ // opened for changes nobody's payment method. A setup nobody finishes stays
61
+ // open; it holds no reference that could be charged.
62
+ model SubscriberPaymentMethodSetup {
63
+ id String @id @default(uuid())
64
+ subscriberId String
65
+ gatewayAccount String
66
+ sessionRef String
67
+ customerRef String
68
+ startedAt DateTime @default(now())
69
+ completedAt DateTime?
70
+
71
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
72
+
73
+ @@unique([gatewayAccount, sessionRef])
74
+ @@index([subscriberId])
75
+ @@map("subscriber_payment_method_setups")
76
+ }
77
+
78
+ // PaymentEventLog — every payment gateway event, handled once.
79
+ // Gateways deliver at least once. An event is claimed on the transaction that
80
+ // writes what it changes, so a retry after a rollback is handled and a retry
81
+ // after a commit finds the claim. `eventId` is unique only within the gateway
82
+ // account that sent it — two accounts side by side never take each other's
83
+ // event for a duplicate. `status` holds the event's kind as SaaSiCat read it
84
+ // (`payment-method-confirmed`, …) and `payload` a summary that names no person.
85
+ // One session is confirmed once, however many events report it: a partial
86
+ // unique index in sql/constraints.postgres.sql, where the reasoning is.
87
+ model PaymentEventLog {
88
+ id String @id @default(cuid())
89
+ gatewayAccount String
90
+ eventId String
91
+ provider String
92
+ sessionId String?
93
+ status String
94
+ payload Json?
95
+ processedAt DateTime @default(now())
96
+
97
+ @@unique([gatewayAccount, eventId])
98
+ @@index([sessionId])
99
+ @@index([status, processedAt])
100
+ }
@@ -11,7 +11,7 @@ related:
11
11
  # Prisma Fragments
12
12
 
13
13
  Prisma-DSL rendering of the SaaSiCat data model, **derived from the normative
14
- sources**: the logical data model in [`docs/data-model.md`](../../../docs/data-model.md)
14
+ sources**: the logical data model in [`docs/explanation/data-model.md`](../../../docs/explanation/data-model.md)
15
15
  and the PostgreSQL artifacts in [`../sql/`](../sql/)
16
16
  (`reference-schema.postgres.sql` is generated FROM these fragments via
17
17
  `pnpm run gen:sql`; `constraints.postgres.sql` carries the invariants Prisma
@@ -28,19 +28,22 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
28
28
 
29
29
  ## Files
30
30
 
31
- | File | Models |
32
- | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
33
- | [`01-subscription.prisma`](01-subscription.prisma) | `Subscription`, `SubscriptionPaymentMethod`, `CheckoutOffer` + Enums |
34
- | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeValidationLog` + Enums |
35
- | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
36
- | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
37
- | [`05-bundle.prisma`](05-bundle.prisma) | `Bundle`, `BundleVersion` |
38
- | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | Capability, feature, quota and marketing catalog models |
39
- | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
40
- | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
41
- | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration`, `PaymentEventLog` + `RegistrationStatus` |
42
- | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
- | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
31
+ | File | Models |
32
+ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
33
+ | [`01-subscription.prisma`](01-subscription.prisma) | `Subscription`, `CheckoutOffer` + Enums |
34
+ | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeValidationLog` + Enums |
35
+ | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
36
+ | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
37
+ | [`05-bundle.prisma`](05-bundle.prisma) | `Bundle`, `BundleVersion` |
38
+ | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | Capability, feature, quota and marketing catalog models |
39
+ | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
40
+ | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
41
+ | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration` + `RegistrationStatus` |
42
+ | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
+ | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
44
+ | [`12-applied-settings.prisma`](12-applied-settings.prisma) | `AppliedSettings`, `SettingsChange` |
45
+ | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection` |
46
+ | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
44
47
 
45
48
  ## How the consumer uses the fragments
46
49
 
@@ -82,16 +85,24 @@ Fields such as `tenantId String` and `userId String?` remain as plain
82
85
  string columns in the fragments; the corresponding `@relation` is left as a
83
86
  comment. The consumer enables them using their own `Tenant`/`User` model names.
84
87
 
88
+ Two models deliberately carry no such pointer: `SubscriptionContract` and
89
+ `SubscriberTenant`. A contract belongs to its subscriber and outlives the tenant
90
+ it was concluded for, so its `tenantId` is a trace — a cascade from the tenant
91
+ would delete the tax record with it, and a restriction would keep the tenant
92
+ from ever being deleted.
93
+
85
94
  ### 3. Table names (`@@map`) are canonical
86
95
 
87
- `subscriptions`, `subscription_payment_methods`, `checkout_offers`, `plans`,
88
- `plan_versions`, `promo_codes`, `promo_code_redemptions`,
89
- `promo_code_validation_logs`, `audit_logs`, `bundles`, `bundle_versions`,
90
- `capability_catalog_entries`, `feature_catalog_entries`,
96
+ `subscriptions`, `checkout_offers`, `plans`, `plan_versions`, `promo_codes`,
97
+ `promo_code_redemptions`, `promo_code_validation_logs`, `audit_logs`, `bundles`,
98
+ `bundle_versions`, `capability_catalog_entries`, `feature_catalog_entries`,
91
99
  `quota_catalog_entries`, `marketing_projections`, `marketing_settings`,
92
100
  `promotions`, `subscription_contracts`, `contract_line_items`,
93
- `pending_registrations`, `payment_event_logs`, `super_admin_users`,
94
- `super_admin_mfa`, `subscription_bundles`.
101
+ `super_admin_users`, `super_admin_mfa`, `subscription_bundles`, `subscribers`,
102
+ `subscriber_tenants`, `subscriber_corrections`, `subscriber_payment_methods`,
103
+ `subscriber_payment_method_setups`.
104
+ `PendingRegistration` and `PaymentEventLog` carry no `@@map` and keep Prisma's
105
+ default names, `"PendingRegistration"` and `"PaymentEventLog"`.
95
106
  Please do **not change** them — otherwise platform migration scripts and the
96
107
  `@saasicat/cli` commands that rely on these names will break.
97
108
 
@@ -124,5 +135,12 @@ CREATE UNIQUE INDEX plan_versions_draft_per_plan
124
135
  - **No add-on tables (#49)** — `subscription_addons`,
125
136
  `unit_addon_versions`, `feature_addon_versions` are not a
126
137
  sales surface; only plan versions + bundles are sold.
127
- - **App-specific tables** (e.g. invoice or bank master data)
128
- belong in the schema of the consuming app, not in the platform.
138
+ - **The application's own invoicing** — invoices to the people it sells to, fees
139
+ it collects from its members, their bank details — belongs in the schema of the
140
+ consuming app. The subscription business is the platform's: subscribers, their
141
+ invoices and their payments are decided in
142
+ [ADR 0012](../../../docs/explanation/adr/0012-the-subscriber-owns-the-commercial-record.md),
143
+ and every model added for them carries `Subscription` or `Subscriber` in its name,
144
+ which keeps it clear of the names applications use for their own invoicing. A
145
+ prefix cannot rule out a name nobody has seen, so `saasicat schema check` reports
146
+ an application model that carries a platform model's name with a different shape.
@@ -80,7 +80,13 @@
80
80
  },
81
81
  "hash": { "type": "string", "pattern": "^sha256-[A-Za-z0-9+/=_-]{32,}$" },
82
82
  "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
83
- "vatRate": { "type": "number", "minimum": 0, "maximum": 100 },
83
+ "vatRate": {
84
+ "type": "number",
85
+ "minimum": 0,
86
+ "maximum": 100,
87
+ "not": { "exclusiveMinimum": 0, "exclusiveMaximum": 1 },
88
+ "description": "VAT rate as a percentage: 19 means 19 %."
89
+ },
84
90
  "features": {
85
91
  "type": "array",
86
92
  "items": {
@@ -134,7 +140,8 @@
134
140
  "bundles": { "$ref": "#/$defs/StandardPageDef" },
135
141
  "marketingCatalog": { "$ref": "#/$defs/StandardPageDef" },
136
142
  "platformEmail": { "$ref": "#/$defs/StandardPageDef" },
137
- "platformEmailHistory": { "$ref": "#/$defs/StandardPageDef" }
143
+ "platformEmailHistory": { "$ref": "#/$defs/StandardPageDef" },
144
+ "settings": { "$ref": "#/$defs/StandardPageDef" }
138
145
  }
139
146
  },
140
147
  "projectPages": {