@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.
- package/README.md +30 -0
- package/acceptance/README.md +3 -3
- package/acceptance/mfa/totp-verify-good-and-bad-code.yaml +6 -6
- package/admin-api.openapi.yaml +328 -77
- package/cli-conventions.md +19 -19
- package/index.cjs +3 -0
- package/index.d.cts +3 -1
- package/index.d.ts +2 -0
- package/index.js +9 -1
- package/package.json +4 -2
- package/prisma-fragments/01-subscription.prisma +34 -47
- package/prisma-fragments/03-plan-versions.prisma +3 -4
- package/prisma-fragments/05-bundle.prisma +4 -5
- package/prisma-fragments/06-catalog-entries.prisma +24 -24
- package/prisma-fragments/07-promotion.prisma +2 -3
- package/prisma-fragments/08-subscription-contract.prisma +42 -5
- package/prisma-fragments/09-pending-registration.prisma +32 -27
- package/prisma-fragments/11-subscription-bundle.prisma +17 -0
- package/prisma-fragments/12-applied-settings.prisma +55 -0
- package/prisma-fragments/13-subscriber.prisma +94 -0
- package/prisma-fragments/14-payments.prisma +100 -0
- package/prisma-fragments/README.md +40 -22
- package/schemas/admin-manifest.schema.json +9 -2
- package/schemas/plan-catalog.schema.json +239 -18
- package/schemas/tenant-ledger.schema.json +236 -0
- package/sql/1.0-a-contract-names-its-subscriber.postgres.sql +383 -0
- package/sql/1.0-a-payment-method-is-a-gateway-reference.postgres.sql +177 -0
- package/sql/1.0-a-settings-change-carries-its-order.postgres.sql +73 -0
- package/sql/1.0-line-items-record-their-money.postgres.sql +134 -0
- package/sql/1.0-remove-project-key.postgres.sql +203 -0
- package/sql/1.0-the-applied-settings-are-recorded.postgres.sql +67 -0
- package/sql/constraints.postgres.sql +76 -0
- 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 —
|
|
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 {
|
|
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.
|
|
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 +
|
|
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
|
-
|
|
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
|
|
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([
|
|
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
|
|
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([
|
|
47
|
-
@@index([
|
|
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.
|
|
10
|
-
//
|
|
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([
|
|
45
|
-
@@index([
|
|
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([
|
|
81
|
-
@@index([
|
|
82
|
-
@@index([
|
|
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([
|
|
152
|
-
@@index([
|
|
153
|
-
@@index([
|
|
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([
|
|
204
|
-
@@index([
|
|
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
|
|
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 ('
|
|
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. "
|
|
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
|
-
//
|
|
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([
|
|
267
|
+
@@index([targetType, locale, priority])
|
|
272
268
|
@@map("marketing_projections")
|
|
273
269
|
}
|
|
274
270
|
|
|
275
271
|
// -----------------------------------------------------------------------------
|
|
276
|
-
// MarketingSettings —
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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
|
|
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
|
|
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([
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
72
|
+
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
|
|
55
73
|
|
|
56
74
|
@@index([tenantId, status, effectiveFrom])
|
|
57
|
-
@@index([
|
|
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
|
|
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
|
|
11
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
68
|
-
billingCycle
|
|
69
|
-
appliedPromoCode
|
|
70
|
-
|
|
71
|
-
|
|
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?
|