@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
package/index.cjs CHANGED
@@ -3,16 +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
7
 
7
8
  module.exports = {
8
9
  adminManifestSchema,
9
10
  planCatalogSchema,
10
11
  promoCodeSchema,
11
12
  auditEventSchema,
13
+ tenantLedgerSchema,
12
14
  SCHEMAS: {
13
15
  adminManifest: adminManifestSchema,
14
16
  planCatalog: planCatalogSchema,
15
17
  promoCode: promoCodeSchema,
16
18
  auditEvent: auditEventSchema,
19
+ tenantLedger: tenantLedgerSchema,
17
20
  },
18
21
  };
package/index.d.cts CHANGED
@@ -1,13 +1,15 @@
1
- // @saasicat/spec — TS-Definitionen für CommonJS-Konsumenten.
1
+ // @saasicat/spec — TypeScript definitions for CommonJS consumers.
2
2
 
3
3
  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
8
 
8
9
  export declare const SCHEMAS: {
9
10
  readonly adminManifest: Record<string, unknown>;
10
11
  readonly planCatalog: Record<string, unknown>;
11
12
  readonly promoCode: Record<string, unknown>;
12
13
  readonly auditEvent: Record<string, unknown>;
14
+ readonly tenantLedger: Record<string, unknown>;
13
15
  };
package/index.d.ts CHANGED
@@ -5,10 +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
9
 
9
10
  export declare const SCHEMAS: {
10
11
  readonly adminManifest: Record<string, unknown>;
11
12
  readonly planCatalog: Record<string, unknown>;
12
13
  readonly promoCode: Record<string, unknown>;
13
14
  readonly auditEvent: Record<string, unknown>;
15
+ readonly tenantLedger: Record<string, unknown>;
14
16
  };
package/index.js CHANGED
@@ -6,12 +6,20 @@ 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
10
 
10
- export { adminManifestSchema, planCatalogSchema, promoCodeSchema, auditEventSchema };
11
+ export {
12
+ adminManifestSchema,
13
+ planCatalogSchema,
14
+ promoCodeSchema,
15
+ auditEventSchema,
16
+ tenantLedgerSchema,
17
+ };
11
18
 
12
19
  export const SCHEMAS = {
13
20
  adminManifest: adminManifestSchema,
14
21
  planCatalog: planCatalogSchema,
15
22
  promoCode: promoCodeSchema,
16
23
  auditEvent: auditEventSchema,
24
+ tenantLedger: tenantLedgerSchema,
17
25
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.0.0-rc.20",
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",
@@ -38,6 +38,7 @@
38
38
  "devDependencies": {
39
39
  "ajv": "^8.17.1",
40
40
  "ajv-formats": "^3.0.1",
41
+ "pg": "^8.22.0",
41
42
  "prisma": "^6.19.0"
42
43
  },
43
44
  "keywords": [
@@ -63,6 +64,7 @@
63
64
  },
64
65
  "scripts": {
65
66
  "gen:sql": "node scripts/generate-reference-sql.mjs",
66
- "test": "node --test tests/*.test.js"
67
+ "test": "node --test tests/*.test.js",
68
+ "test:integration": "node --test 'tests/integration/*.test.js'"
67
69
  }
68
70
  }
@@ -1,5 +1,5 @@
1
1
  // =============================================================================
2
- // SaaS-Platform Prisma fragment: Subscription + Payment method
2
+ // SaaS-Platform Prisma fragment: Subscription + CheckoutOffer
3
3
  // =============================================================================
4
4
  //
5
5
  // REFERENCE SNIPPET — not a standalone schema. Consumers copy
@@ -39,14 +39,6 @@ enum SubscriptionStatus {
39
39
  PENDING_SALES
40
40
  }
41
41
 
42
- enum SubscriptionPaymentType {
43
- CARD
44
- SEPA
45
- PAYPAL
46
- KLARNA
47
- INVOICE
48
- }
49
-
50
42
  // -----------------------------------------------------------------------------
51
43
  // Subscription — one per Tenant. Binds to PlanVersion (see 03-plan-versions).
52
44
  // -----------------------------------------------------------------------------
@@ -61,12 +53,42 @@ model Subscription {
61
53
 
62
54
  trialEndsAt DateTime?
63
55
  startedAt DateTime?
64
- canceledAt DateTime?
56
+ // WHEN the customer said they want out. Never refused: a cancellation is a
57
+ // declaration, and `canceledEffectiveAt` is when it lands. The two are
58
+ // separate because a subscription cancelled in month three of a year is
59
+ // still running, still billed and still entitled until the term ends —
60
+ // rendering it as "cancelled, gone" is how a tenant loses access they paid
61
+ // for. `SubscriptionBundle` already models it this way.
62
+ canceledAt DateTime?
63
+ canceledEffectiveAt DateTime?
65
64
 
66
65
  // Renewal period (maintained by PlanVersionRenewalService).
67
66
  currentPeriodStart DateTime?
68
67
  currentPeriodEnd DateTime?
69
68
 
69
+ // The end of what was committed to, which is not always the end of the
70
+ // period. They coincide while nothing changes, and part company the moment
71
+ // a notice period is configured: a cancellation declared after the window
72
+ // closed lands at the end of the FOLLOWING period, and this is where that
73
+ // date is kept rather than recomputed by whoever asks.
74
+ //
75
+ // Null means no commitment — a trial, or a subscription that predates this
76
+ // field. Readers treat null as "the period end is the answer".
77
+ minimumTermUntil DateTime?
78
+
79
+ // The day of the month this subscription is billed on, 1–31.
80
+ //
81
+ // Written when a period window is opened — at activation, and again at a
82
+ // plan change that resets the window — and never rewritten by a renewal.
83
+ // That is the whole point: a renewal reads it, and reading its own previous
84
+ // result is how the day used to drift. A subscription starting on the 31st
85
+ // was billed on the 28th from its first February onwards.
86
+ //
87
+ // Null on a row that predates this column, and on one with no period at
88
+ // all. Readers fall back to the day of whichever date opened the window,
89
+ // which is the old behaviour for one step and correct for that step.
90
+ billingAnchorDay Int?
91
+
70
92
  // Plan versioning — binds to a published PlanVersion.
71
93
  // `pendingPlanVersion*` concern a version change within the
72
94
  // same plan; `pendingPlan` the change to a different plan. Both
@@ -110,7 +132,6 @@ model Subscription {
110
132
 
111
133
  // Relations — consumer must define `Tenant` and enable the relation.
112
134
  // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
113
- paymentMethod SubscriptionPaymentMethod?
114
135
  promoRedemption PromoCodeRedemption? // see 02-promo-code.prisma
115
136
  planVersion PlanVersion @relation("SubscriptionPlanVersion", fields: [planVersionId], references: [id])
116
137
  pendingPlanVersion PlanVersion? @relation("SubscriptionPendingPlanVersion", fields: [pendingPlanVersionId], references: [id])
@@ -124,39 +145,6 @@ model Subscription {
124
145
  @@map("subscriptions")
125
146
  }
126
147
 
127
- // -----------------------------------------------------------------------------
128
- // SubscriptionPaymentMethod — masked payment data per Subscription.
129
- // -----------------------------------------------------------------------------
130
-
131
- model SubscriptionPaymentMethod {
132
- id String @id @default(uuid())
133
- subscriptionId String @unique
134
- type SubscriptionPaymentType
135
-
136
- // Card (masked)
137
- cardName String?
138
- cardBrand String?
139
- cardLast4 String?
140
- cardExp String?
141
-
142
- // SEPA (masked)
143
- ibanLast4 String?
144
- ibanName String?
145
-
146
- // PayPal
147
- paypalEmail String?
148
-
149
- // Klarna
150
- klarnaPlan String? // "invoice" | "instalments"
151
-
152
- createdAt DateTime @default(now())
153
- updatedAt DateTime @updatedAt
154
-
155
- subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
156
-
157
- @@map("subscription_payment_methods")
158
- }
159
-
160
148
  // -----------------------------------------------------------------------------
161
149
  // CheckoutOffer — immutable package snapshot from the website through to the
162
150
  // Subscription (METAMODELL §17a).
@@ -171,8 +159,7 @@ model SubscriptionPaymentMethod {
171
159
  // -----------------------------------------------------------------------------
172
160
 
173
161
  model CheckoutOffer {
174
- id String @id @default(uuid())
175
- projectKey String
162
+ id String @id @default(uuid())
176
163
 
177
164
  planKey String
178
165
  planVersionId String?
@@ -197,6 +184,6 @@ model CheckoutOffer {
197
184
  createdAt DateTime @default(now())
198
185
  updatedAt DateTime @updatedAt
199
186
 
200
- @@index([projectKey, status])
187
+ @@index([status])
201
188
  @@map("checkout_offers")
202
189
  }
@@ -20,7 +20,7 @@
20
20
  // -----------------------------------------------------------------------------
21
21
  // Plan — root identity of a plan (SPEC_V2 §11.1 M6).
22
22
  // `planKey` is the business plan identity (STARTER / STANDARD / PROFESSIONAL),
23
- // unique per `projectKey`. PlanVersion.planId points **softly** to
23
+ // unique for the installation. PlanVersion.planId points **softly** to
24
24
  // `Plan.planKey` (no DB FK — the binding is enforced via service logic in M6
25
25
  // so that the greenfield cutover can run without a destructive schema
26
26
  // migration).
@@ -32,7 +32,6 @@
32
32
 
33
33
  model Plan {
34
34
  id String @id @default(uuid())
35
- projectKey String
36
35
  planKey String
37
36
  label String
38
37
  description String?
@@ -43,8 +42,8 @@ model Plan {
43
42
  updatedAt DateTime @updatedAt
44
43
  deletedAt DateTime?
45
44
 
46
- @@unique([projectKey, planKey])
47
- @@index([projectKey, deletedAt])
45
+ @@unique([planKey])
46
+ @@index([deletedAt])
48
47
  @@map("plans")
49
48
  }
50
49
 
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Global catalog tables (no tenantId, no RLS). Write access only via the
8
8
  // SUPER_ADMIN guard in the AdminController of the `@saasicat/nest`
9
- // package. Entries are assigned to the respective consumer app via
10
- // `projectKey`.
9
+ // package. One installation serves one application, so `bundleKey` is the
10
+ // whole identity.
11
11
  //
12
12
  // Versioning pattern identical to PlanVersion:
13
13
  // - at most 1 draft (publishedAt IS NULL) per identity — constraint via a
@@ -25,7 +25,6 @@
25
25
 
26
26
  model Bundle {
27
27
  id String @id @default(uuid())
28
- projectKey String // e.g. 'clubapp' | 'demoapp' | …
29
28
  bundleKey String // SCREAMING_SNAKE_CASE, e.g. 'SPORT', 'ACCOUNTING', 'EVERSEND_PEPPOL'
30
29
  label String
31
30
  description String?
@@ -41,8 +40,8 @@ model Bundle {
41
40
 
42
41
  versions BundleVersion[]
43
42
 
44
- @@unique([projectKey, bundleKey])
45
- @@index([projectKey, deletedAt])
43
+ @@unique([bundleKey])
44
+ @@index([deletedAt])
46
45
  @@map("bundles")
47
46
  }
48
47
 
@@ -42,7 +42,6 @@
42
42
 
43
43
  model CapabilityCatalogEntry {
44
44
  id String @id @default(uuid())
45
- projectKey String // e.g. 'clubapp' | 'demoapp' | …
46
45
  capabilityKey String // 'invoice.create' | 'member.archive' | …
47
46
  label String
48
47
  description String?
@@ -77,9 +76,9 @@ model CapabilityCatalogEntry {
77
76
  updatedAt DateTime @updatedAt
78
77
  deletedAt DateTime?
79
78
 
80
- @@unique([projectKey, capabilityKey])
81
- @@index([projectKey, codeStatus])
82
- @@index([projectKey, featureKey])
79
+ @@unique([capabilityKey])
80
+ @@index([codeStatus])
81
+ @@index([featureKey])
83
82
  @@map("capability_catalog_entries")
84
83
  }
85
84
 
@@ -100,7 +99,6 @@ model CapabilityCatalogEntry {
100
99
 
101
100
  model FeatureCatalogEntry {
102
101
  id String @id @default(uuid())
103
- projectKey String
104
102
  featureKey String // 'INVOICE_MANAGEMENT' | 'MEMBER_LIST' | …
105
103
  label String
106
104
  description String?
@@ -148,9 +146,9 @@ model FeatureCatalogEntry {
148
146
  updatedAt DateTime @updatedAt
149
147
  deletedAt DateTime?
150
148
 
151
- @@unique([projectKey, featureKey])
152
- @@index([projectKey, discoveryStatus])
153
- @@index([projectKey, plannedOnly])
149
+ @@unique([featureKey])
150
+ @@index([discoveryStatus])
151
+ @@index([plannedOnly])
154
152
  @@map("feature_catalog_entries")
155
153
  }
156
154
 
@@ -164,7 +162,6 @@ model FeatureCatalogEntry {
164
162
 
165
163
  model QuotaCatalogEntry {
166
164
  id String @id @default(uuid())
167
- projectKey String
168
165
  quotaKey String // 'invoicesPerMonth' | 'members' | 'storageGb' | …
169
166
  label String
170
167
  description String?
@@ -200,8 +197,8 @@ model QuotaCatalogEntry {
200
197
  updatedAt DateTime @updatedAt
201
198
  deletedAt DateTime?
202
199
 
203
- @@unique([projectKey, quotaKey])
204
- @@index([projectKey, discoveryStatus])
200
+ @@unique([quotaKey])
201
+ @@index([discoveryStatus])
205
202
  @@map("quota_catalog_entries")
206
203
  }
207
204
 
@@ -223,8 +220,7 @@ model QuotaCatalogEntry {
223
220
  // -----------------------------------------------------------------------------
224
221
 
225
222
  model MarketingProjection {
226
- id String @id @default(uuid())
227
- projectKey String
223
+ id String @id @default(uuid())
228
224
 
229
225
  // Polymorphic reference to a versioned entity.
230
226
  targetType String // 'PLAN' | 'BUNDLE'
@@ -239,7 +235,7 @@ model MarketingProjection {
239
235
  // not appear on the pricing page.
240
236
  visible Boolean @default(true)
241
237
 
242
- // Optional badge at the top of the card ('Beliebt', 'Neu'). '' = no badge.
238
+ // Optional badge at the top of the card ('Popular', 'New'). '' = no badge.
243
239
  badge String @default("")
244
240
 
245
241
  // Top features for the public catalog card: [{ label, strong }].
@@ -250,7 +246,7 @@ model MarketingProjection {
250
246
  trialEnabled Boolean @default(false)
251
247
  trialDays Int @default(30)
252
248
 
253
- // Optional formatted pricing tag (e.g. "€ 9,90 / Monat" or "auf Anfrage").
249
+ // Optional formatted pricing tag (e.g. "EUR 9.90 / month" or "on request").
254
250
  // If null: pricing is formatted automatically from PlanVersion.monthlyNet
255
251
  // etc. at public-catalog render time.
256
252
  priceTag String?
@@ -261,28 +257,32 @@ model MarketingProjection {
261
257
  // Sort order in the public list (DESC). Higher values first.
262
258
  priority Int @default(0)
263
259
 
264
- // "Empfohlen" star / featured highlight in the UI.
260
+ // The recommended plan: a star, or a featured card, in the UI.
265
261
  highlight Boolean @default(false)
266
262
 
267
263
  createdAt DateTime @default(now())
268
264
  updatedAt DateTime @updatedAt
269
265
 
270
266
  @@unique([targetType, targetVersionId, locale])
271
- @@index([projectKey, targetType, locale, priority])
267
+ @@index([targetType, locale, priority])
272
268
  @@map("marketing_projections")
273
269
  }
274
270
 
275
271
  // -----------------------------------------------------------------------------
276
- // MarketingSettings — project-wide, runtime-editable marketing config.
277
- // One row per project (`projectKey` unique). Currently exactly one field:
278
- // `activeLocales` — the subset of the `availableLocales` pool activated in the
279
- // marketing catalog (the pool comes from the app config, SPEC_V2 §6.5).
280
- // If the row is missing, the full pool counts as active.
272
+ // MarketingSettings — installation-wide, runtime-editable marketing config.
273
+ // At most one row. The constant default gets a caller that omits `id` onto the
274
+ // right row, but it is not what holds the rule — a default does not apply to a
275
+ // caller that supplies one, and a primary key accepts every distinct value. The
276
+ // `CHECK` in sql/constraints.postgres.sql is what holds it; the Prisma DSL
277
+ // cannot say it here.
278
+ // Currently exactly one field: `activeLocales` — the subset of the
279
+ // `availableLocales` pool activated in the marketing catalog (the pool comes
280
+ // from the app config, SPEC_V2 §6.5). If the row is missing, the full pool
281
+ // counts as active.
281
282
  // -----------------------------------------------------------------------------
282
283
 
283
284
  model MarketingSettings {
284
- id String @id @default(uuid())
285
- projectKey String @unique
285
+ id String @id @default("marketing-settings")
286
286
 
287
287
  activeLocales Json @default("[]") // string[]
288
288
 
@@ -14,8 +14,7 @@
14
14
  // via the SUPER_ADMIN guard in the AdminController.
15
15
 
16
16
  model Promotion {
17
- id String @id @default(uuid())
18
- projectKey String // e.g. 'clubapp' | 'demoapp' | …
17
+ id String @id @default(uuid())
19
18
 
20
19
  // Internal label (not public).
21
20
  internalLabel String
@@ -60,6 +59,6 @@ model Promotion {
60
59
  createdAt DateTime @default(now())
61
60
  updatedAt DateTime @updatedAt
62
61
 
63
- @@index([projectKey, targetType, validFrom, validTo])
62
+ @@index([targetType, validFrom, validTo])
64
63
  @@map("promotions")
65
64
  }
@@ -8,6 +8,12 @@
8
8
  // V3 rule: billing and entitlement must not depend on live-mutable catalog
9
9
  // tables. `SubscriptionContract` and `ContractLineItem` therefore store full
10
10
  // snapshots. Catalog FKs are optional and only audit/trace references.
11
+ //
12
+ // A contract belongs to its subscriber (13-subscriber.prisma), not to the
13
+ // tenant, and outlives the tenant (ADR 0012). `tenantId` is kept as a trace of
14
+ // the tenant it was concluded for, and deliberately has no relation to the
15
+ // application's `Tenant` model: a cascade there deletes the tax record with the
16
+ // tenant, and a restriction keeps a tenant from ever being deleted.
11
17
 
12
18
  enum SubscriptionContractStatus {
13
19
  active
@@ -25,9 +31,21 @@ enum ContractLineItemKind {
25
31
  }
26
32
 
27
33
  model SubscriptionContract {
28
- id String @id @default(uuid())
29
- projectKey String
30
- tenantId String
34
+ id String @id @default(uuid())
35
+ tenantId String
36
+
37
+ // Who the contract is between, copied on the day it is concluded: the
38
+ // subscriber as its record stood, and the issuer as `config/saas.yaml`
39
+ // named it — null where it named none. Neither copy follows a later change
40
+ // to the subscriber or the configuration. The invoice email is not copied:
41
+ // it says how the party is reached, not who it is.
42
+ subscriberId String
43
+ subscriberSnapshot Json
44
+ issuerSnapshot Json?
45
+ // The copies were made by the migration that attached contracts concluded
46
+ // before subscribers existed, not at conclusion, and are never presented as
47
+ // what was agreed.
48
+ partiesMigrated Boolean @default(false)
31
49
 
32
50
  status SubscriptionContractStatus @default(active)
33
51
  effectiveFrom DateTime
@@ -51,10 +69,11 @@ model SubscriptionContract {
51
69
  createdAt DateTime @default(now())
52
70
  updatedAt DateTime @updatedAt
53
71
 
54
- // tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
72
+ subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
55
73
 
56
74
  @@index([tenantId, status, effectiveFrom])
57
- @@index([projectKey, status])
75
+ @@index([subscriberId])
76
+ @@index([status])
58
77
  @@index([originalOfferId])
59
78
  @@map("subscription_contracts")
60
79
  }
@@ -76,6 +95,24 @@ model ContractLineItem {
76
95
  priceGross Decimal @db.Decimal(10, 2)
77
96
  billingCycle String // monthly | yearly
78
97
 
98
+ // What this line is worth, as facts rather than as something to work out
99
+ // later. An installation sells in one currency and applies one rate at a
100
+ // time, and both are configured elsewhere — but a row has to keep saying
101
+ // what it meant after either is changed, and a change is a migration
102
+ // precisely because it must not relabel history.
103
+ //
104
+ // `taxRate` is recorded even though `priceNet` and `priceGross` are both
105
+ // here, because the ratio between them is not the rate: it cannot be
106
+ // reproduced for a gross that was rounded, it cannot express an exempt or a
107
+ // reverse-charge line, and it does not survive a rate change.
108
+ //
109
+ // `taxAmount` closes the gap between net and gross exactly, so the row
110
+ // cannot disagree with itself. Deriving it at read time instead would round
111
+ // a second time, once per reader.
112
+ currency String
113
+ taxRate Decimal @db.Decimal(5, 2)
114
+ taxAmount Decimal @db.Decimal(10, 2)
115
+
79
116
  minimumTermUntil DateTime?
80
117
 
81
118
  featuresSnapshot Json @default("[]")
@@ -1,5 +1,5 @@
1
1
  // =============================================================================
2
- // SaaS Platform Prisma fragment: PendingRegistration + PaymentEventLog
2
+ // SaaS Platform Prisma fragment: PendingRegistration
3
3
  // =============================================================================
4
4
  //
5
5
  // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
@@ -7,8 +7,9 @@
7
7
  // Persistence of the multi-step registration and onboarding flow
8
8
  // (`PendingRegistrationService`). A PendingRegistration holds the
9
9
  // intermediate state between step 1 (capturing sign-up data) and the final
10
- // activation (step 4: payment). Only after successful payment does it become
11
- // User + Tenant + Subscription — until then the record deliberately stays
10
+ // activation (step 4: billing address and payment method). Only once the
11
+ // payment gateway confirmed the payment method does it become User + Tenant +
12
+ // Subscriber + Subscription — until then the record deliberately stays
12
13
  // without a foreign key to consumer models.
13
14
  //
14
15
  // Unlike the other fragments, WITHOUT `@@map`: the model was adopted back from
@@ -16,12 +17,14 @@
16
17
  // default names — a subsequent `@@map` would only force a table rename.
17
18
  //
18
19
  // Contract: @saasicat/core (src/registration.types.ts) (PendingRegistration,
19
- // PendingRegistrationRepository, PaymentEventLog).
20
+ // PendingRegistrationRepository). The gateway events a sign-up's
21
+ // confirmation is claimed in are `PaymentEventLog`, in
22
+ // 14-payments.prisma.
20
23
  // Service logic: @saasicat/nest/registration (PendingRegistrationService).
21
24
 
22
25
  // Multi-step registration flow: status of a PendingRegistration
23
26
  // between sign-up-data capture (step 1) and final activation
24
- // after payment (step 4).
27
+ // once the payment method is confirmed (step 4).
25
28
  enum RegistrationStatus {
26
29
  PENDING_EMAIL_VERIFICATION
27
30
  EMAIL_VERIFIED
@@ -64,11 +67,29 @@ model PendingRegistration {
64
67
  // - billingCycle: 'MONTHLY' | 'YEARLY' — determines the price calculation.
65
68
  // - appliedPromoCode: last-applied code (plaintext, only for UI display;
66
69
  // validation runs fresh every time via PromoCodesService).
67
- configJson Json?
68
- billingCycle String?
69
- appliedPromoCode String?
70
- checkoutSessionId String?
71
- checkoutStartedAt DateTime?
70
+ configJson Json?
71
+ billingCycle String?
72
+ appliedPromoCode String?
73
+
74
+ // Billing details (step 4), which the subscriber is created with. The
75
+ // address is required before a payment method is set up; the tax
76
+ // identifiers stay optional.
77
+ addressLine1 String?
78
+ addressLine2 String?
79
+ postalCode String?
80
+ city String?
81
+ country String? // ISO 3166-1 alpha-2
82
+ vatId String?
83
+ taxNumber String?
84
+
85
+ // The payment gateway's session for the payment method. A session id is
86
+ // unique only within the gateway account that opened it, so the account
87
+ // is kept beside it; the customer the gateway created is reused when the
88
+ // step is repeated.
89
+ checkoutSessionId String?
90
+ checkoutGatewayAccount String?
91
+ gatewayCustomerRef String?
92
+ checkoutStartedAt DateTime?
72
93
 
73
94
  expiresAt DateTime
74
95
  createdAt DateTime @default(now())
@@ -76,21 +97,5 @@ model PendingRegistration {
76
97
 
77
98
  @@index([status, expiresAt])
78
99
  @@index([tenantSlug])
79
- }
80
-
81
- // PaymentEventLog — idempotency key for payment webhooks.
82
- // Prevents a doubly delivered webhook (Stripe at-least-once) from triggering
83
- // the final activation more than once. `eventId` is the provider event ID
84
- // (Stripe `event.id`), `tryClaim` uses a @unique INSERT as race protection.
85
- model PaymentEventLog {
86
- id String @id @default(cuid())
87
- eventId String @unique
88
- provider String
89
- sessionId String?
90
- status String
91
- payload Json?
92
- processedAt DateTime @default(now())
93
-
94
- @@index([sessionId])
95
- @@index([status, processedAt])
100
+ @@index([checkoutGatewayAccount, checkoutSessionId])
96
101
  }
@@ -29,6 +29,23 @@ model SubscriptionBundle {
29
29
  // only at `minimumTermEndsAt`.
30
30
  minimumTermEndsAt DateTime?
31
31
 
32
+ // The rhythm this booking is billed in, and the window it is billed for.
33
+ //
34
+ // A bundle may run in a shorter rhythm than its plan but never a longer
35
+ // one, so a yearly bundle exists only beside a yearly plan. Its periods end
36
+ // on the day the plan's do: the first one is short, from the booking to the
37
+ // next occurrence of that day, and every one after it runs anchor to
38
+ // anchor. Aligned at booking rather than trimmed at the end, because a trim
39
+ // means somebody was committed to more than they received.
40
+ //
41
+ // Null on a booking made before these columns existed, and on one whose
42
+ // plan has no period at all — a trial, or a subscription awaiting sales.
43
+ // Readers fall back to the plan's cycle, which is what every booking used
44
+ // before this.
45
+ billingCycle String?
46
+ currentPeriodStart DateTime?
47
+ currentPeriodEnd DateTime?
48
+
32
49
  // Cancellation: requested at `canceledAt`, effective at
33
50
  // `canceledEffectiveAt` (period/term end). Both null = active booking.
34
51
  canceledAt DateTime?