@saasicat/spec 1.0.0-rc.20 → 1.0.0-rc.22
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 +1 -1
- package/admin-api.openapi.yaml +59 -2
- package/index.cjs +3 -3
- package/index.d.cts +2 -2
- package/index.d.ts +2 -2
- package/index.js +3 -3
- package/package.json +1 -1
- package/prisma-fragments/02-promo-code.prisma +38 -0
- package/prisma-fragments/08-subscription-contract.prisma +4 -2
- package/prisma-fragments/13-subscriber.prisma +1 -0
- package/prisma-fragments/14-payments.prisma +8 -0
- package/prisma-fragments/15-subscriber-ledger.prisma +65 -0
- package/prisma-fragments/README.md +18 -17
- package/schemas/admin-manifest.schema.json +1 -1
- package/schemas/promo-code.schema.json +7 -1
- package/schemas/subscriber-ledger.schema.json +100 -0
- package/sql/1.0-a-promo-slot-is-held-through-checkout.postgres.sql +69 -0
- package/sql/1.0-a-subscriber-account-records-its-charges.postgres.sql +101 -0
- package/sql/reference-schema.postgres.sql +65 -0
- package/schemas/tenant-ledger.schema.json +0 -236
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/
|
|
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 |
|
package/admin-api.openapi.yaml
CHANGED
|
@@ -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.
|
|
32
|
+
version: 1.0.0-rc.22
|
|
33
33
|
description: |
|
|
34
34
|
Read and write operations for platform administration:
|
|
35
35
|
tenants, users, subscriptions, promo codes, audit log,
|
|
@@ -125,7 +125,7 @@ paths:
|
|
|
125
125
|
content:
|
|
126
126
|
application/json:
|
|
127
127
|
schema:
|
|
128
|
-
$ref: 'admin-manifest.schema.json'
|
|
128
|
+
$ref: 'schemas/admin-manifest.schema.json'
|
|
129
129
|
'304':
|
|
130
130
|
description: Unchanged since ETag
|
|
131
131
|
'403':
|
|
@@ -368,6 +368,28 @@ paths:
|
|
|
368
368
|
application/json:
|
|
369
369
|
schema: { $ref: '#/components/schemas/SubscriptionDetail' }
|
|
370
370
|
|
|
371
|
+
/tenants/{slug}/charges:
|
|
372
|
+
parameters:
|
|
373
|
+
- $ref: '#/components/parameters/TenantSlug'
|
|
374
|
+
get:
|
|
375
|
+
tags: [subscriptions]
|
|
376
|
+
summary: The charges of the tenant's subscriber, newest first
|
|
377
|
+
description: >-
|
|
378
|
+
Served where the platform keeps a charge journal
|
|
379
|
+
(`tenantBilling.chargeJournal`) and shows tenants
|
|
380
|
+
(`adminResources`); the manifest announces it as `charges.read`.
|
|
381
|
+
Read only, and net: a charge's tax is decided when it is
|
|
382
|
+
invoiced. No total — without invoices and payments, a sum would
|
|
383
|
+
be read as what is owed.
|
|
384
|
+
responses:
|
|
385
|
+
'200':
|
|
386
|
+
description: The account
|
|
387
|
+
content:
|
|
388
|
+
application/json:
|
|
389
|
+
schema: { $ref: '#/components/schemas/AdminSubscriberAccount' }
|
|
390
|
+
'404':
|
|
391
|
+
description: No tenant with this slug (`TENANT_NOT_FOUND`)
|
|
392
|
+
|
|
371
393
|
# ────────────────────────────────────────────────────────
|
|
372
394
|
# Users
|
|
373
395
|
# ────────────────────────────────────────────────────────
|
|
@@ -1915,6 +1937,38 @@ components:
|
|
|
1915
1937
|
promoRedemption:
|
|
1916
1938
|
$ref: '#/components/schemas/PromoRedemptionDto'
|
|
1917
1939
|
|
|
1940
|
+
AdminSubscriberAccount:
|
|
1941
|
+
type: object
|
|
1942
|
+
required: [holder, entries]
|
|
1943
|
+
properties:
|
|
1944
|
+
holder:
|
|
1945
|
+
type: object
|
|
1946
|
+
nullable: true
|
|
1947
|
+
description: Whose account it is; null for a tenant that has no subscriber.
|
|
1948
|
+
required: [id, customerNumber, legalName]
|
|
1949
|
+
properties:
|
|
1950
|
+
id: { type: string }
|
|
1951
|
+
customerNumber: { type: string }
|
|
1952
|
+
legalName: { type: string }
|
|
1953
|
+
entries:
|
|
1954
|
+
type: array
|
|
1955
|
+
description: >-
|
|
1956
|
+
Newest first — by when each charge became due, then by
|
|
1957
|
+
its period, then the plan before its add-ons before its
|
|
1958
|
+
discounts.
|
|
1959
|
+
items:
|
|
1960
|
+
type: object
|
|
1961
|
+
required: [charge, title]
|
|
1962
|
+
properties:
|
|
1963
|
+
charge: { $ref: 'schemas/subscriber-ledger.schema.json' }
|
|
1964
|
+
title:
|
|
1965
|
+
type: string
|
|
1966
|
+
nullable: true
|
|
1967
|
+
description: >-
|
|
1968
|
+
The contract line's title as it was when the
|
|
1969
|
+
contract was concluded; null where that line
|
|
1970
|
+
cannot be read.
|
|
1971
|
+
|
|
1918
1972
|
# ────────── User ──────────
|
|
1919
1973
|
AdminUserRow:
|
|
1920
1974
|
type: object
|
|
@@ -1979,6 +2033,9 @@ components:
|
|
|
1979
2033
|
validUntil: { type: string, format: date-time, nullable: true }
|
|
1980
2034
|
maxRedemptions: { type: integer, nullable: true }
|
|
1981
2035
|
redemptionsCount: { type: integer }
|
|
2036
|
+
heldCount:
|
|
2037
|
+
type: integer
|
|
2038
|
+
description: Slots held for checkouts that started and have not concluded.
|
|
1982
2039
|
appliesToPlans:
|
|
1983
2040
|
type: array
|
|
1984
2041
|
items: { type: string }
|
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
|
|
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
|
-
|
|
13
|
+
subscriberLedgerSchema,
|
|
14
14
|
SCHEMAS: {
|
|
15
15
|
adminManifest: adminManifestSchema,
|
|
16
16
|
planCatalog: planCatalogSchema,
|
|
17
17
|
promoCode: promoCodeSchema,
|
|
18
18
|
auditEvent: auditEventSchema,
|
|
19
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
subscriberLedger: subscriberLedgerSchema,
|
|
25
25
|
};
|
package/package.json
CHANGED
|
@@ -56,6 +56,10 @@ model PromoCode {
|
|
|
56
56
|
// Availability-relevant — counts only redemptions with status=ACTIVE.
|
|
57
57
|
// Incremented atomically in the redeem() path, decremented on trial cancellation.
|
|
58
58
|
redemptionsCount Int @default(0)
|
|
59
|
+
// Slots kept for checkouts that have started and not concluded (see
|
|
60
|
+
// PromoCodeHold). A code has a free slot while redemptionsCount + heldCount
|
|
61
|
+
// stays below maxRedemptions; the status says nothing about holds.
|
|
62
|
+
heldCount Int @default(0)
|
|
59
63
|
|
|
60
64
|
// Plan whitelist as String[] — values are plan IDs from plans.yaml.
|
|
61
65
|
// Consumers using a Postgres enum can cast the field locally.
|
|
@@ -86,6 +90,7 @@ model PromoCode {
|
|
|
86
90
|
|
|
87
91
|
redemptions PromoCodeRedemption[]
|
|
88
92
|
validationLogs PromoCodeValidationLog[]
|
|
93
|
+
holds PromoCodeHold[]
|
|
89
94
|
|
|
90
95
|
@@index([status, validFrom, validUntil])
|
|
91
96
|
@@index([campaignTag])
|
|
@@ -126,6 +131,39 @@ model PromoCodeRedemption {
|
|
|
126
131
|
@@map("promo_code_redemptions")
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
// -----------------------------------------------------------------------------
|
|
135
|
+
// PromoCodeHold — a slot of a code kept for a checkout from its start, so that
|
|
136
|
+
// a customer who reaches the payment form keeps the code they started with.
|
|
137
|
+
// For a sign-up, `expiresAt` is the last moment a confirmation of its payment
|
|
138
|
+
// form can arrive, not the checkout's lifetime. It counts in
|
|
139
|
+
// PromoCode.heldCount, and it ends in one of three ways, each deleting the row
|
|
140
|
+
// and giving the count back: the conclusion turns it into the redemption, the
|
|
141
|
+
// checkout offer's code changes, or `expiresAt` passes. It is a reservation
|
|
142
|
+
// rather than a record — what a customer was granted is the
|
|
143
|
+
// PromoCodeRedemption.
|
|
144
|
+
// -----------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
model PromoCodeHold {
|
|
147
|
+
id String @id @default(uuid())
|
|
148
|
+
promoCodeId String
|
|
149
|
+
// Not a foreign key: an offer deleted while it holds a slot leaves the hold
|
|
150
|
+
// to expire, which gives the slot back. A cascade would delete the row and
|
|
151
|
+
// leave heldCount counting it.
|
|
152
|
+
checkoutOfferId String @unique
|
|
153
|
+
expiresAt DateTime
|
|
154
|
+
// The id of the transaction a conclusion runs on (`txid_current()`), set on
|
|
155
|
+
// that transaction and gone before it commits: the redemption running on
|
|
156
|
+
// the same transaction finds its slot by it, and no other one can.
|
|
157
|
+
handedOverTx BigInt?
|
|
158
|
+
createdAt DateTime @default(now())
|
|
159
|
+
|
|
160
|
+
promoCode PromoCode @relation(fields: [promoCodeId], references: [id])
|
|
161
|
+
|
|
162
|
+
@@index([promoCodeId, expiresAt])
|
|
163
|
+
@@index([expiresAt])
|
|
164
|
+
@@map("promo_code_holds")
|
|
165
|
+
}
|
|
166
|
+
|
|
129
167
|
// -----------------------------------------------------------------------------
|
|
130
168
|
// PromoCodeValidationLog — audit trail of all validation attempts
|
|
131
169
|
// (including failed NOT_FOUND/EXPIRED), for anti-abuse analysis.
|
|
@@ -64,7 +64,8 @@ model SubscriptionContract {
|
|
|
64
64
|
promoCodeSnapshots Json @default("[]")
|
|
65
65
|
termsSnapshot Json?
|
|
66
66
|
|
|
67
|
-
lineItems
|
|
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
|
|
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
|
}
|
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
// payment method, a partial unique index in sql/constraints.postgres.sql;
|
|
16
16
|
// the one a newer payment method replaced stays as `REPLACED`.
|
|
17
17
|
//
|
|
18
|
+
// Within an account a reference belongs to exactly one subscriber, which is
|
|
19
|
+
// what `@@unique([gatewayAccount, paymentMethodRef])` below holds. The port
|
|
20
|
+
// draws the same boundary for reading: a confirmation carrying a reference
|
|
21
|
+
// another subscriber holds is refused rather than recorded, and a read by
|
|
22
|
+
// reference names the subscriber it asks about. Both are reached on the
|
|
23
|
+
// gateway's callback, which arrives without a session — an installation that
|
|
24
|
+
// keeps its tenants apart with a policy on this table lifts it there.
|
|
25
|
+
//
|
|
18
26
|
// Contract: @saasicat/core (SubscriberPaymentMethodRepository, PaymentEventLog).
|
|
19
27
|
// Both are needed wherever payment methods are taken — self-registration or
|
|
20
28
|
// not — because every gateway callback is claimed in `PaymentEventLog`.
|
|
@@ -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
|
+
}
|
|
@@ -28,22 +28,23 @@ 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`, `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`
|
|
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`, `PromoCodeHold`, `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` |
|
|
47
|
+
| [`15-subscriber-ledger.prisma`](15-subscriber-ledger.prisma) | `SubscriberLedgerEntry` |
|
|
47
48
|
|
|
48
49
|
## How the consumer uses the fragments
|
|
49
50
|
|
|
@@ -94,7 +95,7 @@ from ever being deleted.
|
|
|
94
95
|
### 3. Table names (`@@map`) are canonical
|
|
95
96
|
|
|
96
97
|
`subscriptions`, `checkout_offers`, `plans`, `plan_versions`, `promo_codes`,
|
|
97
|
-
`promo_code_redemptions`, `promo_code_validation_logs`, `audit_logs`, `bundles`,
|
|
98
|
+
`promo_code_redemptions`, `promo_code_holds`, `promo_code_validation_logs`, `audit_logs`, `bundles`,
|
|
98
99
|
`bundle_versions`, `capability_catalog_entries`, `feature_catalog_entries`,
|
|
99
100
|
`quota_catalog_entries`, `marketing_projections`, `marketing_settings`,
|
|
100
101
|
`promotions`, `subscription_contracts`, `contract_line_items`,
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"properties": {
|
|
77
77
|
"source": {
|
|
78
78
|
"type": "string",
|
|
79
|
-
"description": "
|
|
79
|
+
"description": "Where the plans and features were read from: 'database' for the ones an operator publishes, 'given' for a catalogue passed in at start"
|
|
80
80
|
},
|
|
81
81
|
"hash": { "type": "string", "pattern": "^sha256-[A-Za-z0-9+/=_-]{32,}$" },
|
|
82
82
|
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
|
|
@@ -129,7 +129,8 @@
|
|
|
129
129
|
"value",
|
|
130
130
|
"durationType",
|
|
131
131
|
"status",
|
|
132
|
-
"redemptionsCount"
|
|
132
|
+
"redemptionsCount",
|
|
133
|
+
"heldCount"
|
|
133
134
|
],
|
|
134
135
|
"additionalProperties": false,
|
|
135
136
|
"properties": {
|
|
@@ -143,6 +144,11 @@
|
|
|
143
144
|
"validUntil": { "type": ["string", "null"], "format": "date-time" },
|
|
144
145
|
"maxRedemptions": { "type": ["integer", "null"], "minimum": 1 },
|
|
145
146
|
"redemptionsCount": { "type": "integer", "minimum": 0 },
|
|
147
|
+
"heldCount": {
|
|
148
|
+
"type": "integer",
|
|
149
|
+
"minimum": 0,
|
|
150
|
+
"description": "Slots held for checkouts that started and have not concluded. A code has a free slot while redemptionsCount + heldCount is below maxRedemptions."
|
|
151
|
+
},
|
|
146
152
|
"appliesToPlans": {
|
|
147
153
|
"type": "array",
|
|
148
154
|
"items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" }
|
|
@@ -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,69 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- SaaSiCat 1.0 — a promo code's slot is held from the start of a checkout.
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
--
|
|
5
|
+
-- A sign-up that starts its checkout with a promo code keeps a slot of that
|
|
6
|
+
-- code until the checkout concludes, or until a confirmation of its payment
|
|
7
|
+
-- form can no longer arrive, so that a customer who reaches the payment form is
|
|
8
|
+
-- not refused afterwards because somebody else took the code's last redemption
|
|
9
|
+
-- in between. Run it the same way as the other files in
|
|
10
|
+
-- this directory, before `db push` where you use one:
|
|
11
|
+
--
|
|
12
|
+
-- psql "$DATABASE_URL" -f 1.0-a-promo-slot-is-held-through-checkout.postgres.sql
|
|
13
|
+
--
|
|
14
|
+
-- What it touches, in one transaction, and only where `promo_codes` is there:
|
|
15
|
+
--
|
|
16
|
+
-- 1. `promo_codes` — gains `heldCount`, the slots held for checkouts, next to
|
|
17
|
+
-- `redemptionsCount`. Every existing code starts at 0: no checkout held a
|
|
18
|
+
-- slot before this file.
|
|
19
|
+
-- 2. `promo_code_holds` — created with its indexes and its foreign key to
|
|
20
|
+
-- `promo_codes`. One row per checkout offer holding a slot; the row is
|
|
21
|
+
-- deleted when the hold ends, so the table stays small.
|
|
22
|
+
--
|
|
23
|
+
-- Safe to run again: the column, the table and the indexes are created only
|
|
24
|
+
-- where they are missing, and the foreign key only where its name is not taken.
|
|
25
|
+
-- On a database created from `reference-schema.postgres.sql` the whole file does
|
|
26
|
+
-- nothing at all.
|
|
27
|
+
|
|
28
|
+
BEGIN;
|
|
29
|
+
|
|
30
|
+
DO $$
|
|
31
|
+
BEGIN
|
|
32
|
+
IF to_regclass('promo_codes') IS NULL THEN
|
|
33
|
+
RAISE NOTICE 'promo_codes is not present — nothing to do; this installation takes no promo codes.';
|
|
34
|
+
RETURN;
|
|
35
|
+
END IF;
|
|
36
|
+
|
|
37
|
+
ALTER TABLE "promo_codes" ADD COLUMN IF NOT EXISTS "heldCount" INTEGER NOT NULL DEFAULT 0;
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS "promo_code_holds" (
|
|
40
|
+
"id" TEXT NOT NULL,
|
|
41
|
+
"promoCodeId" TEXT NOT NULL,
|
|
42
|
+
"checkoutOfferId" TEXT NOT NULL,
|
|
43
|
+
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
44
|
+
"handedOverTx" BIGINT,
|
|
45
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
46
|
+
|
|
47
|
+
CONSTRAINT "promo_code_holds_pkey" PRIMARY KEY ("id")
|
|
48
|
+
);
|
|
49
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "promo_code_holds_checkoutOfferId_key"
|
|
50
|
+
ON "promo_code_holds"("checkoutOfferId");
|
|
51
|
+
CREATE INDEX IF NOT EXISTS "promo_code_holds_promoCodeId_expiresAt_idx"
|
|
52
|
+
ON "promo_code_holds"("promoCodeId", "expiresAt");
|
|
53
|
+
CREATE INDEX IF NOT EXISTS "promo_code_holds_expiresAt_idx"
|
|
54
|
+
ON "promo_code_holds"("expiresAt");
|
|
55
|
+
|
|
56
|
+
-- `ADD CONSTRAINT` has no `IF NOT EXISTS`.
|
|
57
|
+
IF NOT EXISTS (
|
|
58
|
+
SELECT 1 FROM pg_constraint
|
|
59
|
+
WHERE conrelid = to_regclass('promo_code_holds')
|
|
60
|
+
AND conname = 'promo_code_holds_promoCodeId_fkey'
|
|
61
|
+
) THEN
|
|
62
|
+
ALTER TABLE "promo_code_holds"
|
|
63
|
+
ADD CONSTRAINT "promo_code_holds_promoCodeId_fkey"
|
|
64
|
+
FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id")
|
|
65
|
+
ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
66
|
+
END IF;
|
|
67
|
+
END $$;
|
|
68
|
+
|
|
69
|
+
COMMIT;
|
|
@@ -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;
|
|
@@ -17,6 +17,7 @@
|
|
|
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
|
|
20
21
|
-- plus the normative constraints from sql/constraints.postgres.sql.
|
|
21
22
|
-- Do not edit by hand — change the fragments/constraints and regenerate.
|
|
22
23
|
|
|
@@ -128,6 +129,7 @@ CREATE TABLE "promo_codes" (
|
|
|
128
129
|
"validUntil" TIMESTAMP(3),
|
|
129
130
|
"maxRedemptions" INTEGER,
|
|
130
131
|
"redemptionsCount" INTEGER NOT NULL DEFAULT 0,
|
|
132
|
+
"heldCount" INTEGER NOT NULL DEFAULT 0,
|
|
131
133
|
"appliesToPlans" TEXT[],
|
|
132
134
|
"appliesToBilling" "BillingCycle",
|
|
133
135
|
"firstTimeCustomersOnly" BOOLEAN NOT NULL DEFAULT true,
|
|
@@ -164,6 +166,18 @@ CREATE TABLE "promo_code_redemptions" (
|
|
|
164
166
|
CONSTRAINT "promo_code_redemptions_pkey" PRIMARY KEY ("id")
|
|
165
167
|
);
|
|
166
168
|
|
|
169
|
+
-- CreateTable
|
|
170
|
+
CREATE TABLE "promo_code_holds" (
|
|
171
|
+
"id" TEXT NOT NULL,
|
|
172
|
+
"promoCodeId" TEXT NOT NULL,
|
|
173
|
+
"checkoutOfferId" TEXT NOT NULL,
|
|
174
|
+
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
175
|
+
"handedOverTx" BIGINT,
|
|
176
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
177
|
+
|
|
178
|
+
CONSTRAINT "promo_code_holds_pkey" PRIMARY KEY ("id")
|
|
179
|
+
);
|
|
180
|
+
|
|
167
181
|
-- CreateTable
|
|
168
182
|
CREATE TABLE "promo_code_validation_logs" (
|
|
169
183
|
"id" TEXT NOT NULL,
|
|
@@ -671,6 +685,27 @@ CREATE TABLE "PaymentEventLog" (
|
|
|
671
685
|
CONSTRAINT "PaymentEventLog_pkey" PRIMARY KEY ("id")
|
|
672
686
|
);
|
|
673
687
|
|
|
688
|
+
-- CreateTable
|
|
689
|
+
CREATE TABLE "subscriber_ledger_entries" (
|
|
690
|
+
"id" TEXT NOT NULL,
|
|
691
|
+
"subscriberId" TEXT NOT NULL,
|
|
692
|
+
"tenantId" TEXT NOT NULL,
|
|
693
|
+
"subscriptionId" TEXT NOT NULL,
|
|
694
|
+
"contractId" TEXT NOT NULL,
|
|
695
|
+
"contractLineItemId" TEXT NOT NULL,
|
|
696
|
+
"origin" TEXT NOT NULL,
|
|
697
|
+
"source" TEXT NOT NULL,
|
|
698
|
+
"sourceRef" TEXT NOT NULL,
|
|
699
|
+
"periodStart" TIMESTAMP(3) NOT NULL,
|
|
700
|
+
"periodEnd" TIMESTAMP(3) NOT NULL,
|
|
701
|
+
"currency" TEXT NOT NULL,
|
|
702
|
+
"amountNet" DECIMAL(10,2) NOT NULL,
|
|
703
|
+
"bookedAt" TIMESTAMP(3) NOT NULL,
|
|
704
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
705
|
+
|
|
706
|
+
CONSTRAINT "subscriber_ledger_entries_pkey" PRIMARY KEY ("id")
|
|
707
|
+
);
|
|
708
|
+
|
|
674
709
|
-- CreateIndex
|
|
675
710
|
CREATE UNIQUE INDEX "subscriptions_tenantId_key" ON "subscriptions"("tenantId");
|
|
676
711
|
|
|
@@ -710,6 +745,15 @@ CREATE INDEX "promo_code_redemptions_promoCodeId_status_idx" ON "promo_code_rede
|
|
|
710
745
|
-- CreateIndex
|
|
711
746
|
CREATE INDEX "promo_code_redemptions_startsAt_endsAt_idx" ON "promo_code_redemptions"("startsAt", "endsAt");
|
|
712
747
|
|
|
748
|
+
-- CreateIndex
|
|
749
|
+
CREATE UNIQUE INDEX "promo_code_holds_checkoutOfferId_key" ON "promo_code_holds"("checkoutOfferId");
|
|
750
|
+
|
|
751
|
+
-- CreateIndex
|
|
752
|
+
CREATE INDEX "promo_code_holds_promoCodeId_expiresAt_idx" ON "promo_code_holds"("promoCodeId", "expiresAt");
|
|
753
|
+
|
|
754
|
+
-- CreateIndex
|
|
755
|
+
CREATE INDEX "promo_code_holds_expiresAt_idx" ON "promo_code_holds"("expiresAt");
|
|
756
|
+
|
|
713
757
|
-- CreateIndex
|
|
714
758
|
CREATE INDEX "promo_code_validation_logs_codeAttempt_createdAt_idx" ON "promo_code_validation_logs"("codeAttempt", "createdAt");
|
|
715
759
|
|
|
@@ -887,6 +931,15 @@ CREATE INDEX "PaymentEventLog_status_processedAt_idx" ON "PaymentEventLog"("stat
|
|
|
887
931
|
-- CreateIndex
|
|
888
932
|
CREATE UNIQUE INDEX "PaymentEventLog_gatewayAccount_eventId_key" ON "PaymentEventLog"("gatewayAccount", "eventId");
|
|
889
933
|
|
|
934
|
+
-- CreateIndex
|
|
935
|
+
CREATE INDEX "subscriber_ledger_entries_subscriberId_bookedAt_idx" ON "subscriber_ledger_entries"("subscriberId", "bookedAt");
|
|
936
|
+
|
|
937
|
+
-- CreateIndex
|
|
938
|
+
CREATE INDEX "subscriber_ledger_entries_contractLineItemId_idx" ON "subscriber_ledger_entries"("contractLineItemId");
|
|
939
|
+
|
|
940
|
+
-- CreateIndex
|
|
941
|
+
CREATE UNIQUE INDEX "subscriber_ledger_entries_subscriptionId_source_sourceRef_p_key" ON "subscriber_ledger_entries"("subscriptionId", "source", "sourceRef", "periodStart", "origin");
|
|
942
|
+
|
|
890
943
|
-- AddForeignKey
|
|
891
944
|
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planVersionId_fkey" FOREIGN KEY ("planVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
892
945
|
|
|
@@ -899,6 +952,9 @@ ALTER TABLE "promo_code_redemptions" ADD CONSTRAINT "promo_code_redemptions_prom
|
|
|
899
952
|
-- AddForeignKey
|
|
900
953
|
ALTER TABLE "promo_code_redemptions" ADD CONSTRAINT "promo_code_redemptions_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
901
954
|
|
|
955
|
+
-- AddForeignKey
|
|
956
|
+
ALTER TABLE "promo_code_holds" ADD CONSTRAINT "promo_code_holds_promoCodeId_fkey" FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
957
|
+
|
|
902
958
|
-- AddForeignKey
|
|
903
959
|
ALTER TABLE "promo_code_validation_logs" ADD CONSTRAINT "promo_code_validation_logs_promoCodeId_fkey" FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
904
960
|
|
|
@@ -935,6 +991,15 @@ ALTER TABLE "subscriber_payment_methods" ADD CONSTRAINT "subscriber_payment_meth
|
|
|
935
991
|
-- AddForeignKey
|
|
936
992
|
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;
|
|
937
993
|
|
|
994
|
+
-- AddForeignKey
|
|
995
|
+
ALTER TABLE "subscriber_ledger_entries" ADD CONSTRAINT "subscriber_ledger_entries_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
996
|
+
|
|
997
|
+
-- AddForeignKey
|
|
998
|
+
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;
|
|
999
|
+
|
|
1000
|
+
-- AddForeignKey
|
|
1001
|
+
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;
|
|
1002
|
+
|
|
938
1003
|
-- =============================================================================
|
|
939
1004
|
-- SaaSiCat — normative PostgreSQL constraints the Prisma DSL cannot express.
|
|
940
1005
|
-- =============================================================================
|
|
@@ -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
|
-
}
|