@saasicat/spec 1.0.0-rc.16 → 1.0.0-rc.17
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/admin-api.openapi.yaml +1 -1
- package/package.json +1 -1
- package/prisma-fragments/01-subscription.prisma +1 -43
- package/prisma-fragments/09-pending-registration.prisma +32 -27
- package/prisma-fragments/13-subscriber.prisma +5 -3
- package/prisma-fragments/14-payments.prisma +100 -0
- package/prisma-fragments/README.md +24 -22
- package/schemas/plan-catalog.schema.json +57 -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 +14 -2
- package/sql/1.0-the-applied-settings-are-recorded.postgres.sql +9 -1
- package/sql/constraints.postgres.sql +23 -0
- package/sql/reference-schema.postgres.sql +115 -48
package/admin-api.openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -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
|
// -----------------------------------------------------------------------------
|
|
@@ -140,7 +132,6 @@ model Subscription {
|
|
|
140
132
|
|
|
141
133
|
// Relations — consumer must define `Tenant` and enable the relation.
|
|
142
134
|
// tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
|
143
|
-
paymentMethod SubscriptionPaymentMethod?
|
|
144
135
|
promoRedemption PromoCodeRedemption? // see 02-promo-code.prisma
|
|
145
136
|
planVersion PlanVersion @relation("SubscriptionPlanVersion", fields: [planVersionId], references: [id])
|
|
146
137
|
pendingPlanVersion PlanVersion? @relation("SubscriptionPendingPlanVersion", fields: [pendingPlanVersionId], references: [id])
|
|
@@ -154,39 +145,6 @@ model Subscription {
|
|
|
154
145
|
@@map("subscriptions")
|
|
155
146
|
}
|
|
156
147
|
|
|
157
|
-
// -----------------------------------------------------------------------------
|
|
158
|
-
// SubscriptionPaymentMethod — masked payment data per Subscription.
|
|
159
|
-
// -----------------------------------------------------------------------------
|
|
160
|
-
|
|
161
|
-
model SubscriptionPaymentMethod {
|
|
162
|
-
id String @id @default(uuid())
|
|
163
|
-
subscriptionId String @unique
|
|
164
|
-
type SubscriptionPaymentType
|
|
165
|
-
|
|
166
|
-
// Card (masked)
|
|
167
|
-
cardName String?
|
|
168
|
-
cardBrand String?
|
|
169
|
-
cardLast4 String?
|
|
170
|
-
cardExp String?
|
|
171
|
-
|
|
172
|
-
// SEPA (masked)
|
|
173
|
-
ibanLast4 String?
|
|
174
|
-
ibanName String?
|
|
175
|
-
|
|
176
|
-
// PayPal
|
|
177
|
-
paypalEmail String?
|
|
178
|
-
|
|
179
|
-
// Klarna
|
|
180
|
-
klarnaPlan String? // "invoice" | "instalments"
|
|
181
|
-
|
|
182
|
-
createdAt DateTime @default(now())
|
|
183
|
-
updatedAt DateTime @updatedAt
|
|
184
|
-
|
|
185
|
-
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
|
186
|
-
|
|
187
|
-
@@map("subscription_payment_methods")
|
|
188
|
-
}
|
|
189
|
-
|
|
190
148
|
// -----------------------------------------------------------------------------
|
|
191
149
|
// CheckoutOffer — immutable package snapshot from the website through to the
|
|
192
150
|
// Subscription (METAMODELL §17a).
|
|
@@ -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
|
}
|
|
@@ -47,9 +47,11 @@ model Subscriber {
|
|
|
47
47
|
createdAt DateTime @default(now())
|
|
48
48
|
updatedAt DateTime @updatedAt
|
|
49
49
|
|
|
50
|
-
tenants
|
|
51
|
-
corrections
|
|
52
|
-
contracts
|
|
50
|
+
tenants SubscriberTenant[]
|
|
51
|
+
corrections SubscriberCorrection[]
|
|
52
|
+
contracts SubscriptionContract[]
|
|
53
|
+
paymentMethods SubscriberPaymentMethod[] // see 14-payments.prisma
|
|
54
|
+
paymentMethodSetups SubscriberPaymentMethodSetup[]
|
|
53
55
|
|
|
54
56
|
@@map("subscribers")
|
|
55
57
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// SaaS platform Prisma fragment: SubscriberPaymentMethod, its setups, PaymentEventLog
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
|
|
6
|
+
//
|
|
7
|
+
// How a subscriber pays, as the payment gateway's reference to it. The card
|
|
8
|
+
// number or the IBAN never reaches this table: the person enters them in the
|
|
9
|
+
// gateway's own form, and what is kept is the gateway's reference and the
|
|
10
|
+
// masked details that tell one payment method from another (ADR 0012).
|
|
11
|
+
//
|
|
12
|
+
// A reference means something only to the gateway account that issued it —
|
|
13
|
+
// the merchant account named in `config/saas.yaml#payments.accounts` — so
|
|
14
|
+
// every row records that account. A subscriber has at most one `ACTIVE`
|
|
15
|
+
// payment method, a partial unique index in sql/constraints.postgres.sql;
|
|
16
|
+
// the one a newer payment method replaced stays as `REPLACED`.
|
|
17
|
+
//
|
|
18
|
+
// Contract: @saasicat/core (SubscriberPaymentMethodRepository, PaymentEventLog).
|
|
19
|
+
// Both are needed wherever payment methods are taken — self-registration or
|
|
20
|
+
// not — because every gateway callback is claimed in `PaymentEventLog`.
|
|
21
|
+
|
|
22
|
+
model SubscriberPaymentMethod {
|
|
23
|
+
id String @id @default(uuid())
|
|
24
|
+
subscriberId String
|
|
25
|
+
|
|
26
|
+
// The account the references below belong to, and its provider when the
|
|
27
|
+
// payment method was confirmed.
|
|
28
|
+
gatewayAccount String
|
|
29
|
+
provider String
|
|
30
|
+
customerRef String
|
|
31
|
+
paymentMethodRef String
|
|
32
|
+
|
|
33
|
+
// What tells the payment method apart, never enough to pay with it.
|
|
34
|
+
type String // card | sepa_debit
|
|
35
|
+
brand String?
|
|
36
|
+
last4 String
|
|
37
|
+
expiryMonth Int?
|
|
38
|
+
expiryYear Int?
|
|
39
|
+
country String? // ISO 3166-1 alpha-2
|
|
40
|
+
bankCode String?
|
|
41
|
+
mandateReference String?
|
|
42
|
+
|
|
43
|
+
status String @default("ACTIVE") // ACTIVE | REPLACED
|
|
44
|
+
confirmedAt DateTime
|
|
45
|
+
replacedAt DateTime?
|
|
46
|
+
createdAt DateTime @default(now())
|
|
47
|
+
|
|
48
|
+
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
|
|
49
|
+
|
|
50
|
+
@@unique([gatewayAccount, paymentMethodRef])
|
|
51
|
+
@@index([subscriberId, status])
|
|
52
|
+
@@index([gatewayAccount, status])
|
|
53
|
+
@@map("subscriber_payment_methods")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A change of payment method a tenant started: the gateway session opened for
|
|
57
|
+
// the subscriber. A confirmation is recorded only against the setup whose
|
|
58
|
+
// account, session and subscriber it names, and completes it on the same
|
|
59
|
+
// transaction — so a callback naming another subscriber than the session was
|
|
60
|
+
// opened for changes nobody's payment method. A setup nobody finishes stays
|
|
61
|
+
// open; it holds no reference that could be charged.
|
|
62
|
+
model SubscriberPaymentMethodSetup {
|
|
63
|
+
id String @id @default(uuid())
|
|
64
|
+
subscriberId String
|
|
65
|
+
gatewayAccount String
|
|
66
|
+
sessionRef String
|
|
67
|
+
customerRef String
|
|
68
|
+
startedAt DateTime @default(now())
|
|
69
|
+
completedAt DateTime?
|
|
70
|
+
|
|
71
|
+
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Restrict)
|
|
72
|
+
|
|
73
|
+
@@unique([gatewayAccount, sessionRef])
|
|
74
|
+
@@index([subscriberId])
|
|
75
|
+
@@map("subscriber_payment_method_setups")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// PaymentEventLog — every payment gateway event, handled once.
|
|
79
|
+
// Gateways deliver at least once. An event is claimed on the transaction that
|
|
80
|
+
// writes what it changes, so a retry after a rollback is handled and a retry
|
|
81
|
+
// after a commit finds the claim. `eventId` is unique only within the gateway
|
|
82
|
+
// account that sent it — two accounts side by side never take each other's
|
|
83
|
+
// event for a duplicate. `status` holds the event's kind as SaaSiCat read it
|
|
84
|
+
// (`payment-method-confirmed`, …) and `payload` a summary that names no person.
|
|
85
|
+
// One session is confirmed once, however many events report it: a partial
|
|
86
|
+
// unique index in sql/constraints.postgres.sql, where the reasoning is.
|
|
87
|
+
model PaymentEventLog {
|
|
88
|
+
id String @id @default(cuid())
|
|
89
|
+
gatewayAccount String
|
|
90
|
+
eventId String
|
|
91
|
+
provider String
|
|
92
|
+
sessionId String?
|
|
93
|
+
status String
|
|
94
|
+
payload Json?
|
|
95
|
+
processedAt DateTime @default(now())
|
|
96
|
+
|
|
97
|
+
@@unique([gatewayAccount, eventId])
|
|
98
|
+
@@index([sessionId])
|
|
99
|
+
@@index([status, processedAt])
|
|
100
|
+
}
|
|
@@ -28,21 +28,22 @@ regenerated after fragment changes (`tests/reference-sql-drift.test.js`).
|
|
|
28
28
|
|
|
29
29
|
## Files
|
|
30
30
|
|
|
31
|
-
| File | Models
|
|
32
|
-
| -------------------------------------------------------------------- |
|
|
33
|
-
| [`01-subscription.prisma`](01-subscription.prisma) | `Subscription`, `
|
|
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
|
|
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`
|
|
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` |
|
|
46
47
|
|
|
47
48
|
## How the consumer uses the fragments
|
|
48
49
|
|
|
@@ -92,15 +93,16 @@ from ever being deleted.
|
|
|
92
93
|
|
|
93
94
|
### 3. Table names (`@@map`) are canonical
|
|
94
95
|
|
|
95
|
-
`subscriptions`, `
|
|
96
|
-
`
|
|
97
|
-
`
|
|
98
|
-
`capability_catalog_entries`, `feature_catalog_entries`,
|
|
96
|
+
`subscriptions`, `checkout_offers`, `plans`, `plan_versions`, `promo_codes`,
|
|
97
|
+
`promo_code_redemptions`, `promo_code_validation_logs`, `audit_logs`, `bundles`,
|
|
98
|
+
`bundle_versions`, `capability_catalog_entries`, `feature_catalog_entries`,
|
|
99
99
|
`quota_catalog_entries`, `marketing_projections`, `marketing_settings`,
|
|
100
100
|
`promotions`, `subscription_contracts`, `contract_line_items`,
|
|
101
|
-
`
|
|
102
|
-
`
|
|
103
|
-
`
|
|
101
|
+
`super_admin_users`, `super_admin_mfa`, `subscription_bundles`, `subscribers`,
|
|
102
|
+
`subscriber_tenants`, `subscriber_corrections`, `subscriber_payment_methods`,
|
|
103
|
+
`subscriber_payment_method_setups`.
|
|
104
|
+
`PendingRegistration` and `PaymentEventLog` carry no `@@map` and keep Prisma's
|
|
105
|
+
default names, `"PendingRegistration"` and `"PaymentEventLog"`.
|
|
104
106
|
Please do **not change** them — otherwise platform migration scripts and the
|
|
105
107
|
`@saasicat/cli` commands that rely on these names will break.
|
|
106
108
|
|
|
@@ -195,6 +195,40 @@
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
},
|
|
198
|
+
"payments": {
|
|
199
|
+
"type": "object",
|
|
200
|
+
"additionalProperties": false,
|
|
201
|
+
"required": ["accounts", "returnUrlOrigins"],
|
|
202
|
+
"description": "The payment gateway accounts this installation takes payment methods through. SaaSiCat keeps the gateway's reference to a payment method and its masked details, never a card number or an IBAN. The keys and the webhook secret of each account are bound in the application's code from the environment and never written here.",
|
|
203
|
+
"properties": {
|
|
204
|
+
"newPaymentMethods": {
|
|
205
|
+
"type": "string",
|
|
206
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,39}$",
|
|
207
|
+
"description": "The account a new payment method is taken at, at sign-up and when a tenant changes its payment method. It names one of `accounts`, which then has to list its `methods`. Omitted, no new payment method is taken, and the accounts listed stay for the references they hold."
|
|
208
|
+
},
|
|
209
|
+
"returnUrlOrigins": {
|
|
210
|
+
"type": "array",
|
|
211
|
+
"minItems": 1,
|
|
212
|
+
"uniqueItems": true,
|
|
213
|
+
"items": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"pattern": "^https?://[^/?#\\s]+$"
|
|
216
|
+
},
|
|
217
|
+
"description": "The origins a gateway's form may send a person back to, e.g. `https://app.example.com`: the scheme, the host and a port, no path. A sign-up or a tenant naming a success or cancel URL at any other origin is refused, so the operator's own payment form cannot be made to forward somebody to a page of a stranger's choosing."
|
|
218
|
+
},
|
|
219
|
+
"accounts": {
|
|
220
|
+
"type": "object",
|
|
221
|
+
"minProperties": 1,
|
|
222
|
+
"propertyNames": {
|
|
223
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
|
|
224
|
+
},
|
|
225
|
+
"additionalProperties": {
|
|
226
|
+
"$ref": "#/$defs/PaymentGatewayAccount"
|
|
227
|
+
},
|
|
228
|
+
"description": "Every gateway account by the name its webhook route carries: `/webhooks/payment/<name>`. An account that still holds a payment method in use stays listed after another takes the new ones, so its callbacks keep being handled; a start that finds a stored reference to an account missing here refuses, naming the account."
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
198
232
|
"features": {
|
|
199
233
|
"type": "array",
|
|
200
234
|
"items": {
|
|
@@ -211,6 +245,29 @@
|
|
|
211
245
|
}
|
|
212
246
|
},
|
|
213
247
|
"$defs": {
|
|
248
|
+
"PaymentGatewayAccount": {
|
|
249
|
+
"type": "object",
|
|
250
|
+
"additionalProperties": false,
|
|
251
|
+
"required": ["provider"],
|
|
252
|
+
"description": "One account at a payment gateway: a merchant account whose keys the application binds.",
|
|
253
|
+
"properties": {
|
|
254
|
+
"provider": {
|
|
255
|
+
"type": "string",
|
|
256
|
+
"pattern": "^[a-z0-9][a-z0-9-]{0,39}$",
|
|
257
|
+
"description": "The gateway the account is at, as its adapter names itself: `stripe` for @saasicat/payment-stripe. A start refuses an account whose bound adapter names another."
|
|
258
|
+
},
|
|
259
|
+
"methods": {
|
|
260
|
+
"type": "array",
|
|
261
|
+
"minItems": 1,
|
|
262
|
+
"uniqueItems": true,
|
|
263
|
+
"items": {
|
|
264
|
+
"type": "string",
|
|
265
|
+
"enum": ["card", "sepa_debit"]
|
|
266
|
+
},
|
|
267
|
+
"description": "The payment methods a new payment method may be at this account: `card`, `sepa_debit`. Required for the account `newPaymentMethods` names and read for no other."
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
214
271
|
"FeatureDef": {
|
|
215
272
|
"type": "object",
|
|
216
273
|
"required": ["key"],
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- SaaSiCat 1.0 — a payment method is the gateway's reference, kept for the subscriber.
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
--
|
|
5
|
+
-- SaaSiCat takes payment methods through a payment gateway: the person enters
|
|
6
|
+
-- the card or the IBAN in the gateway's own form, and SaaSiCat keeps the
|
|
7
|
+
-- gateway's reference with the masked details, for the subscriber (ADR 0012).
|
|
8
|
+
-- Run it BEFORE `db push`, and after
|
|
9
|
+
-- `1.0-a-contract-names-its-subscriber.postgres.sql`, whose `subscribers` table
|
|
10
|
+
-- it points at:
|
|
11
|
+
--
|
|
12
|
+
-- psql "$DATABASE_URL" -f 1.0-a-payment-method-is-a-gateway-reference.postgres.sql
|
|
13
|
+
--
|
|
14
|
+
-- What it touches, in one transaction, each part only where its table is there:
|
|
15
|
+
--
|
|
16
|
+
-- 1. `subscriber_payment_methods` — created with its indexes, its foreign key
|
|
17
|
+
-- to `subscribers` and the partial unique index that allows one payment
|
|
18
|
+
-- method in use per subscriber — and `subscriber_payment_method_setups`,
|
|
19
|
+
-- the changes of payment method a tenant started. Skipped where
|
|
20
|
+
-- `subscribers` is missing.
|
|
21
|
+
-- 2. `"PaymentEventLog"` — gains `gatewayAccount`. An event identifier is
|
|
22
|
+
-- unique only within the gateway account that sent it, so the unique index
|
|
23
|
+
-- on `eventId` alone is replaced by one on both. An event recorded before
|
|
24
|
+
-- this file carries no account; it is given its `provider` as one, which
|
|
25
|
+
-- keeps the old identifiers unique and matches no account a gateway sends
|
|
26
|
+
-- from now on. A second index holds one confirmation per gateway session
|
|
27
|
+
-- (`sql/constraints.postgres.sql` says what it is for). It matches no row
|
|
28
|
+
-- recorded before this file: `status` then held `PaymentEventStatus`,
|
|
29
|
+
-- `SUCCEEDED` or `FAILED`, never the event kinds SaaSiCat reads today. A
|
|
30
|
+
-- row that does carry one is refused by name rather than by a unique
|
|
31
|
+
-- violation, which only a database run against an unreleased build of
|
|
32
|
+
-- this change can hold.
|
|
33
|
+
-- 3. `"PendingRegistration"` — gains the billing details step 4 asks for,
|
|
34
|
+
-- `checkoutGatewayAccount` and `gatewayCustomerRef`. A sign-up whose
|
|
35
|
+
-- checkout started before this file has no account beside its session, so
|
|
36
|
+
-- no confirmation can find it; the person repeats step 4.
|
|
37
|
+
--
|
|
38
|
+
-- What it leaves alone: `subscription_payment_methods` and the enum
|
|
39
|
+
-- `SubscriptionPaymentType`. The fragments no longer declare them, and nothing
|
|
40
|
+
-- in SaaSiCat reads them, but an application may still write there and the rows
|
|
41
|
+
-- are its own. Drop both once nothing does — `docs/guides/upgrade-to-1.0.md`
|
|
42
|
+
-- has the statements. `db push` offers to drop them as well.
|
|
43
|
+
--
|
|
44
|
+
-- Safe to run again: every table, column and index is created only where it is
|
|
45
|
+
-- missing, the foreign key only where its name is not taken, the event log's
|
|
46
|
+
-- account is filled only where it is empty, and the old unique index is dropped
|
|
47
|
+
-- only where it still exists. On a database created from
|
|
48
|
+
-- `reference-schema.postgres.sql` the whole file does nothing at all.
|
|
49
|
+
|
|
50
|
+
BEGIN;
|
|
51
|
+
|
|
52
|
+
DO $$
|
|
53
|
+
DECLARE
|
|
54
|
+
twice RECORD;
|
|
55
|
+
BEGIN
|
|
56
|
+
-- 1. The payment methods, for the subscriber ------------------------------
|
|
57
|
+
|
|
58
|
+
IF to_regclass('subscribers') IS NULL THEN
|
|
59
|
+
RAISE NOTICE 'subscribers is not present — subscriber_payment_methods is not created; run 1.0-a-contract-names-its-subscriber.postgres.sql first where contracts are kept.';
|
|
60
|
+
ELSE
|
|
61
|
+
CREATE TABLE IF NOT EXISTS "subscriber_payment_methods" (
|
|
62
|
+
"id" TEXT NOT NULL,
|
|
63
|
+
"subscriberId" TEXT NOT NULL,
|
|
64
|
+
"gatewayAccount" TEXT NOT NULL,
|
|
65
|
+
"provider" TEXT NOT NULL,
|
|
66
|
+
"customerRef" TEXT NOT NULL,
|
|
67
|
+
"paymentMethodRef" TEXT NOT NULL,
|
|
68
|
+
"type" TEXT NOT NULL,
|
|
69
|
+
"brand" TEXT,
|
|
70
|
+
"last4" TEXT NOT NULL,
|
|
71
|
+
"expiryMonth" INTEGER,
|
|
72
|
+
"expiryYear" INTEGER,
|
|
73
|
+
"country" TEXT,
|
|
74
|
+
"bankCode" TEXT,
|
|
75
|
+
"mandateReference" TEXT,
|
|
76
|
+
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
|
77
|
+
"confirmedAt" TIMESTAMP(3) NOT NULL,
|
|
78
|
+
"replacedAt" TIMESTAMP(3),
|
|
79
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
80
|
+
CONSTRAINT "subscriber_payment_methods_pkey" PRIMARY KEY ("id")
|
|
81
|
+
);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS "subscriber_payment_methods_subscriberId_status_idx"
|
|
83
|
+
ON "subscriber_payment_methods"("subscriberId", "status");
|
|
84
|
+
CREATE INDEX IF NOT EXISTS "subscriber_payment_methods_gatewayAccount_status_idx"
|
|
85
|
+
ON "subscriber_payment_methods"("gatewayAccount", "status");
|
|
86
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "subscriber_payment_methods_gatewayAccount_paymentMethodRef_key"
|
|
87
|
+
ON "subscriber_payment_methods"("gatewayAccount", "paymentMethodRef");
|
|
88
|
+
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_payment_methods_active_per_subscriber
|
|
89
|
+
ON subscriber_payment_methods ("subscriberId") WHERE "status" = 'ACTIVE';
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS "subscriber_payment_method_setups" (
|
|
92
|
+
"id" TEXT NOT NULL,
|
|
93
|
+
"subscriberId" TEXT NOT NULL,
|
|
94
|
+
"gatewayAccount" TEXT NOT NULL,
|
|
95
|
+
"sessionRef" TEXT NOT NULL,
|
|
96
|
+
"customerRef" TEXT NOT NULL,
|
|
97
|
+
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
98
|
+
"completedAt" TIMESTAMP(3),
|
|
99
|
+
CONSTRAINT "subscriber_payment_method_setups_pkey" PRIMARY KEY ("id")
|
|
100
|
+
);
|
|
101
|
+
CREATE INDEX IF NOT EXISTS "subscriber_payment_method_setups_subscriberId_idx"
|
|
102
|
+
ON "subscriber_payment_method_setups"("subscriberId");
|
|
103
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "subscriber_payment_method_setups_gatewayAccount_sessionRef_key"
|
|
104
|
+
ON "subscriber_payment_method_setups"("gatewayAccount", "sessionRef");
|
|
105
|
+
|
|
106
|
+
-- `ADD CONSTRAINT` has no `IF NOT EXISTS`.
|
|
107
|
+
IF NOT EXISTS (
|
|
108
|
+
SELECT 1 FROM pg_constraint
|
|
109
|
+
WHERE conrelid = to_regclass('subscriber_payment_methods')
|
|
110
|
+
AND conname = 'subscriber_payment_methods_subscriberId_fkey'
|
|
111
|
+
) THEN
|
|
112
|
+
ALTER TABLE "subscriber_payment_methods"
|
|
113
|
+
ADD CONSTRAINT "subscriber_payment_methods_subscriberId_fkey"
|
|
114
|
+
FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
|
|
115
|
+
ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
116
|
+
END IF;
|
|
117
|
+
IF NOT EXISTS (
|
|
118
|
+
SELECT 1 FROM pg_constraint
|
|
119
|
+
WHERE conrelid = to_regclass('subscriber_payment_method_setups')
|
|
120
|
+
AND conname = 'subscriber_payment_method_setups_subscriberId_fkey'
|
|
121
|
+
) THEN
|
|
122
|
+
ALTER TABLE "subscriber_payment_method_setups"
|
|
123
|
+
ADD CONSTRAINT "subscriber_payment_method_setups_subscriberId_fkey"
|
|
124
|
+
FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id")
|
|
125
|
+
ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
126
|
+
END IF;
|
|
127
|
+
END IF;
|
|
128
|
+
|
|
129
|
+
-- 2. Gateway events, per account ------------------------------------------
|
|
130
|
+
|
|
131
|
+
IF to_regclass('"PaymentEventLog"') IS NULL THEN
|
|
132
|
+
RAISE NOTICE 'PaymentEventLog is not present — an installation without self-registration; left alone.';
|
|
133
|
+
ELSE
|
|
134
|
+
ALTER TABLE "PaymentEventLog" ADD COLUMN IF NOT EXISTS "gatewayAccount" TEXT;
|
|
135
|
+
UPDATE "PaymentEventLog" SET "gatewayAccount" = "provider" WHERE "gatewayAccount" IS NULL;
|
|
136
|
+
ALTER TABLE "PaymentEventLog" ALTER COLUMN "gatewayAccount" SET NOT NULL;
|
|
137
|
+
DROP INDEX IF EXISTS "PaymentEventLog_eventId_key";
|
|
138
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "PaymentEventLog_gatewayAccount_eventId_key"
|
|
139
|
+
ON "PaymentEventLog"("gatewayAccount", "eventId");
|
|
140
|
+
SELECT "gatewayAccount", "sessionId", count(*) AS claims
|
|
141
|
+
INTO twice
|
|
142
|
+
FROM "PaymentEventLog"
|
|
143
|
+
WHERE "status" = 'payment-method-confirmed'
|
|
144
|
+
AND "sessionId" IS NOT NULL
|
|
145
|
+
GROUP BY "gatewayAccount", "sessionId"
|
|
146
|
+
HAVING count(*) > 1
|
|
147
|
+
LIMIT 1;
|
|
148
|
+
IF FOUND THEN
|
|
149
|
+
RAISE EXCEPTION
|
|
150
|
+
'PaymentEventLog holds % confirmations of session % at account %, and one session is confirmed once. Only a database run against an unreleased build of this change can hold them. Keep the confirmation that was acted on, delete the others, and apply this file again.',
|
|
151
|
+
twice.claims, twice."sessionId", twice."gatewayAccount";
|
|
152
|
+
END IF;
|
|
153
|
+
CREATE UNIQUE INDEX IF NOT EXISTS payment_event_log_confirmation_per_session
|
|
154
|
+
ON "PaymentEventLog"("gatewayAccount", "sessionId")
|
|
155
|
+
WHERE "status" = 'payment-method-confirmed';
|
|
156
|
+
END IF;
|
|
157
|
+
|
|
158
|
+
-- 3. Sign-up: billing details and the account a session belongs to -------
|
|
159
|
+
|
|
160
|
+
IF to_regclass('"PendingRegistration"') IS NULL THEN
|
|
161
|
+
RAISE NOTICE 'PendingRegistration is not present — an installation without self-registration; left alone.';
|
|
162
|
+
ELSE
|
|
163
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "addressLine1" TEXT;
|
|
164
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "addressLine2" TEXT;
|
|
165
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "postalCode" TEXT;
|
|
166
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "city" TEXT;
|
|
167
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "country" TEXT;
|
|
168
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "vatId" TEXT;
|
|
169
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "taxNumber" TEXT;
|
|
170
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "checkoutGatewayAccount" TEXT;
|
|
171
|
+
ALTER TABLE "PendingRegistration" ADD COLUMN IF NOT EXISTS "gatewayCustomerRef" TEXT;
|
|
172
|
+
CREATE INDEX IF NOT EXISTS "PendingRegistration_checkoutGatewayAccount_checkoutSessionI_idx"
|
|
173
|
+
ON "PendingRegistration"("checkoutGatewayAccount", "checkoutSessionId");
|
|
174
|
+
END IF;
|
|
175
|
+
END $$;
|
|
176
|
+
|
|
177
|
+
COMMIT;
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
--
|
|
17
17
|
-- psql "$DATABASE_URL" -f 1.0-a-settings-change-carries-its-order.postgres.sql
|
|
18
18
|
--
|
|
19
|
-
--
|
|
19
|
+
-- An installation without `settings_changes` is left alone. Safe to run again:
|
|
20
|
+
-- the block runs only while the column is missing. Rows
|
|
20
21
|
-- recorded before it existed are numbered in the order they were listed until
|
|
21
22
|
-- now — `noticedAt`, then `id` — so nothing an operator saw changes place, and
|
|
22
23
|
-- the numbering continues after them. The unique index is created afterwards,
|
|
@@ -28,6 +29,11 @@ BEGIN;
|
|
|
28
29
|
|
|
29
30
|
DO $$
|
|
30
31
|
BEGIN
|
|
32
|
+
IF to_regclass('settings_changes') IS NULL THEN
|
|
33
|
+
RAISE NOTICE 'settings_changes is not present — nothing to migrate.';
|
|
34
|
+
RETURN;
|
|
35
|
+
END IF;
|
|
36
|
+
|
|
31
37
|
IF EXISTS (
|
|
32
38
|
SELECT 1
|
|
33
39
|
FROM information_schema.columns
|
|
@@ -56,6 +62,12 @@ BEGIN
|
|
|
56
62
|
);
|
|
57
63
|
END $$;
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
DO $$
|
|
66
|
+
BEGIN
|
|
67
|
+
IF to_regclass('settings_changes') IS NULL THEN
|
|
68
|
+
RETURN; -- said once already, by the block above
|
|
69
|
+
END IF;
|
|
70
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "settings_changes_seq_key" ON "settings_changes"("seq");
|
|
71
|
+
END $$;
|
|
60
72
|
|
|
61
73
|
COMMIT;
|
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
-- what holds it to one row — the constant default only lands a caller that
|
|
20
20
|
-- omits the id on the right one.
|
|
21
21
|
--
|
|
22
|
-
--
|
|
22
|
+
-- A table this file creates has the shape the fragments declare now, the `seq`
|
|
23
|
+
-- that orders the changes included: a fresh installation applies it after
|
|
24
|
+
-- `1.0-a-settings-change-carries-its-order.postgres.sql`, which finds no table
|
|
25
|
+
-- to add the column to, and `db push` refuses to add a unique column to a table
|
|
26
|
+
-- it cannot see is empty.
|
|
27
|
+
--
|
|
28
|
+
-- Safe to run again: both tables and the indexes are created only where they are
|
|
23
29
|
-- missing, and the constraint is dropped before it is added because
|
|
24
30
|
-- `ADD CONSTRAINT` has no `IF NOT EXISTS`. On a database created from
|
|
25
31
|
-- `reference-schema.postgres.sql` the whole file does nothing at all.
|
|
@@ -43,6 +49,7 @@ ALTER TABLE "applied_settings"
|
|
|
43
49
|
|
|
44
50
|
CREATE TABLE IF NOT EXISTS "settings_changes" (
|
|
45
51
|
"id" TEXT NOT NULL,
|
|
52
|
+
"seq" SERIAL NOT NULL,
|
|
46
53
|
"noticedAt" TIMESTAMP(3) NOT NULL,
|
|
47
54
|
"source" TEXT NOT NULL,
|
|
48
55
|
"previous" JSONB NOT NULL,
|
|
@@ -55,5 +62,6 @@ CREATE TABLE IF NOT EXISTS "settings_changes" (
|
|
|
55
62
|
|
|
56
63
|
CREATE INDEX IF NOT EXISTS "settings_changes_acknowledgedAt_noticedAt_idx"
|
|
57
64
|
ON "settings_changes"("acknowledgedAt", "noticedAt");
|
|
65
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "settings_changes_seq_key" ON "settings_changes"("seq");
|
|
58
66
|
|
|
59
67
|
COMMIT;
|
|
@@ -57,6 +57,29 @@ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_tenant
|
|
|
57
57
|
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_subscriber
|
|
58
58
|
ON subscriber_tenants ("subscriberId") WHERE "unlinkedAt" IS NULL;
|
|
59
59
|
|
|
60
|
+
-- A subscriber has at most ONE payment method in use. The one a newer payment
|
|
61
|
+
-- method replaced keeps its row as `REPLACED`, so it does not count here.
|
|
62
|
+
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_payment_methods_active_per_subscriber
|
|
63
|
+
ON subscriber_payment_methods ("subscriberId") WHERE "status" = 'ACTIVE';
|
|
64
|
+
|
|
65
|
+
-- A gateway session is confirmed ONCE.
|
|
66
|
+
--
|
|
67
|
+
-- A gateway delivers every event at least once, and it may report one session
|
|
68
|
+
-- through more than one event — a completed form and the payment method behind
|
|
69
|
+
-- it can arrive as two. `eventId` tells those apart, so both would be claimed,
|
|
70
|
+
-- and both would record the session's payment method: for a sign-up that means
|
|
71
|
+
-- a second tenant for one registration.
|
|
72
|
+
--
|
|
73
|
+
-- Both adapters claim with `ON CONFLICT DO NOTHING` and no conflict target, so
|
|
74
|
+
-- this index turns the second confirmation into no row rather than an error,
|
|
75
|
+
-- and the caller reads it as the duplicate it is. Partial on the kind, because
|
|
76
|
+
-- a failed setup and an event SaaSiCat does not act on say nothing about the
|
|
77
|
+
-- session being confirmed. A claim carrying no session does not collide:
|
|
78
|
+
-- PostgreSQL counts nulls as distinct in a unique index.
|
|
79
|
+
CREATE UNIQUE INDEX IF NOT EXISTS payment_event_log_confirmation_per_session
|
|
80
|
+
ON "PaymentEventLog" ("gatewayAccount", "sessionId")
|
|
81
|
+
WHERE "status" = 'payment-method-confirmed';
|
|
82
|
+
|
|
60
83
|
-- Customer numbers count from 10001, so a number has five digits up to 99999
|
|
61
84
|
-- and none reads as a count of subscribers. Two statements: the first makes
|
|
62
85
|
-- 10001 where the sequence starts over, which a restart of its identity reads,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
-- prisma-fragments/11-subscription-bundle.prisma
|
|
17
17
|
-- prisma-fragments/12-applied-settings.prisma
|
|
18
18
|
-- prisma-fragments/13-subscriber.prisma
|
|
19
|
+
-- prisma-fragments/14-payments.prisma
|
|
19
20
|
-- plus the normative constraints from sql/constraints.postgres.sql.
|
|
20
21
|
-- Do not edit by hand — change the fragments/constraints and regenerate.
|
|
21
22
|
|
|
@@ -28,9 +29,6 @@ CREATE TYPE "BillingCycle" AS ENUM ('MONTHLY', 'YEARLY');
|
|
|
28
29
|
-- CreateEnum
|
|
29
30
|
CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELED', 'PENDING_SALES');
|
|
30
31
|
|
|
31
|
-
-- CreateEnum
|
|
32
|
-
CREATE TYPE "SubscriptionPaymentType" AS ENUM ('CARD', 'SEPA', 'PAYPAL', 'KLARNA', 'INVOICE');
|
|
33
|
-
|
|
34
32
|
-- CreateEnum
|
|
35
33
|
CREATE TYPE "PromoCodeValueType" AS ENUM ('PERCENT', 'ABSOLUTE');
|
|
36
34
|
|
|
@@ -94,25 +92,6 @@ CREATE TABLE "subscriptions" (
|
|
|
94
92
|
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
|
|
95
93
|
);
|
|
96
94
|
|
|
97
|
-
-- CreateTable
|
|
98
|
-
CREATE TABLE "subscription_payment_methods" (
|
|
99
|
-
"id" TEXT NOT NULL,
|
|
100
|
-
"subscriptionId" TEXT NOT NULL,
|
|
101
|
-
"type" "SubscriptionPaymentType" NOT NULL,
|
|
102
|
-
"cardName" TEXT,
|
|
103
|
-
"cardBrand" TEXT,
|
|
104
|
-
"cardLast4" TEXT,
|
|
105
|
-
"cardExp" TEXT,
|
|
106
|
-
"ibanLast4" TEXT,
|
|
107
|
-
"ibanName" TEXT,
|
|
108
|
-
"paypalEmail" TEXT,
|
|
109
|
-
"klarnaPlan" TEXT,
|
|
110
|
-
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
111
|
-
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
112
|
-
|
|
113
|
-
CONSTRAINT "subscription_payment_methods_pkey" PRIMARY KEY ("id")
|
|
114
|
-
);
|
|
115
|
-
|
|
116
95
|
-- CreateTable
|
|
117
96
|
CREATE TABLE "checkout_offers" (
|
|
118
97
|
"id" TEXT NOT NULL,
|
|
@@ -508,7 +487,16 @@ CREATE TABLE "PendingRegistration" (
|
|
|
508
487
|
"configJson" JSONB,
|
|
509
488
|
"billingCycle" TEXT,
|
|
510
489
|
"appliedPromoCode" TEXT,
|
|
490
|
+
"addressLine1" TEXT,
|
|
491
|
+
"addressLine2" TEXT,
|
|
492
|
+
"postalCode" TEXT,
|
|
493
|
+
"city" TEXT,
|
|
494
|
+
"country" TEXT,
|
|
495
|
+
"vatId" TEXT,
|
|
496
|
+
"taxNumber" TEXT,
|
|
511
497
|
"checkoutSessionId" TEXT,
|
|
498
|
+
"checkoutGatewayAccount" TEXT,
|
|
499
|
+
"gatewayCustomerRef" TEXT,
|
|
512
500
|
"checkoutStartedAt" TIMESTAMP(3),
|
|
513
501
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
514
502
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -517,19 +505,6 @@ CREATE TABLE "PendingRegistration" (
|
|
|
517
505
|
CONSTRAINT "PendingRegistration_pkey" PRIMARY KEY ("id")
|
|
518
506
|
);
|
|
519
507
|
|
|
520
|
-
-- CreateTable
|
|
521
|
-
CREATE TABLE "PaymentEventLog" (
|
|
522
|
-
"id" TEXT NOT NULL,
|
|
523
|
-
"eventId" TEXT NOT NULL,
|
|
524
|
-
"provider" TEXT NOT NULL,
|
|
525
|
-
"sessionId" TEXT,
|
|
526
|
-
"status" TEXT NOT NULL,
|
|
527
|
-
"payload" JSONB,
|
|
528
|
-
"processedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
529
|
-
|
|
530
|
-
CONSTRAINT "PaymentEventLog_pkey" PRIMARY KEY ("id")
|
|
531
|
-
);
|
|
532
|
-
|
|
533
508
|
-- CreateTable
|
|
534
509
|
CREATE TABLE "super_admin_users" (
|
|
535
510
|
"id" TEXT NOT NULL,
|
|
@@ -645,6 +620,57 @@ CREATE TABLE "subscriber_corrections" (
|
|
|
645
620
|
CONSTRAINT "subscriber_corrections_pkey" PRIMARY KEY ("id")
|
|
646
621
|
);
|
|
647
622
|
|
|
623
|
+
-- CreateTable
|
|
624
|
+
CREATE TABLE "subscriber_payment_methods" (
|
|
625
|
+
"id" TEXT NOT NULL,
|
|
626
|
+
"subscriberId" TEXT NOT NULL,
|
|
627
|
+
"gatewayAccount" TEXT NOT NULL,
|
|
628
|
+
"provider" TEXT NOT NULL,
|
|
629
|
+
"customerRef" TEXT NOT NULL,
|
|
630
|
+
"paymentMethodRef" TEXT NOT NULL,
|
|
631
|
+
"type" TEXT NOT NULL,
|
|
632
|
+
"brand" TEXT,
|
|
633
|
+
"last4" TEXT NOT NULL,
|
|
634
|
+
"expiryMonth" INTEGER,
|
|
635
|
+
"expiryYear" INTEGER,
|
|
636
|
+
"country" TEXT,
|
|
637
|
+
"bankCode" TEXT,
|
|
638
|
+
"mandateReference" TEXT,
|
|
639
|
+
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
|
640
|
+
"confirmedAt" TIMESTAMP(3) NOT NULL,
|
|
641
|
+
"replacedAt" TIMESTAMP(3),
|
|
642
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
643
|
+
|
|
644
|
+
CONSTRAINT "subscriber_payment_methods_pkey" PRIMARY KEY ("id")
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
-- CreateTable
|
|
648
|
+
CREATE TABLE "subscriber_payment_method_setups" (
|
|
649
|
+
"id" TEXT NOT NULL,
|
|
650
|
+
"subscriberId" TEXT NOT NULL,
|
|
651
|
+
"gatewayAccount" TEXT NOT NULL,
|
|
652
|
+
"sessionRef" TEXT NOT NULL,
|
|
653
|
+
"customerRef" TEXT NOT NULL,
|
|
654
|
+
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
655
|
+
"completedAt" TIMESTAMP(3),
|
|
656
|
+
|
|
657
|
+
CONSTRAINT "subscriber_payment_method_setups_pkey" PRIMARY KEY ("id")
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
-- CreateTable
|
|
661
|
+
CREATE TABLE "PaymentEventLog" (
|
|
662
|
+
"id" TEXT NOT NULL,
|
|
663
|
+
"gatewayAccount" TEXT NOT NULL,
|
|
664
|
+
"eventId" TEXT NOT NULL,
|
|
665
|
+
"provider" TEXT NOT NULL,
|
|
666
|
+
"sessionId" TEXT,
|
|
667
|
+
"status" TEXT NOT NULL,
|
|
668
|
+
"payload" JSONB,
|
|
669
|
+
"processedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
670
|
+
|
|
671
|
+
CONSTRAINT "PaymentEventLog_pkey" PRIMARY KEY ("id")
|
|
672
|
+
);
|
|
673
|
+
|
|
648
674
|
-- CreateIndex
|
|
649
675
|
CREATE UNIQUE INDEX "subscriptions_tenantId_key" ON "subscriptions"("tenantId");
|
|
650
676
|
|
|
@@ -660,9 +686,6 @@ CREATE INDEX "subscriptions_pendingPlanVersionId_idx" ON "subscriptions"("pendin
|
|
|
660
686
|
-- CreateIndex
|
|
661
687
|
CREATE INDEX "subscriptions_currentPeriodEnd_idx" ON "subscriptions"("currentPeriodEnd");
|
|
662
688
|
|
|
663
|
-
-- CreateIndex
|
|
664
|
-
CREATE UNIQUE INDEX "subscription_payment_methods_subscriptionId_key" ON "subscription_payment_methods"("subscriptionId");
|
|
665
|
-
|
|
666
689
|
-- CreateIndex
|
|
667
690
|
CREATE INDEX "checkout_offers_status_idx" ON "checkout_offers"("status");
|
|
668
691
|
|
|
@@ -805,13 +828,7 @@ CREATE INDEX "PendingRegistration_status_expiresAt_idx" ON "PendingRegistration"
|
|
|
805
828
|
CREATE INDEX "PendingRegistration_tenantSlug_idx" ON "PendingRegistration"("tenantSlug");
|
|
806
829
|
|
|
807
830
|
-- CreateIndex
|
|
808
|
-
CREATE
|
|
809
|
-
|
|
810
|
-
-- CreateIndex
|
|
811
|
-
CREATE INDEX "PaymentEventLog_sessionId_idx" ON "PaymentEventLog"("sessionId");
|
|
812
|
-
|
|
813
|
-
-- CreateIndex
|
|
814
|
-
CREATE INDEX "PaymentEventLog_status_processedAt_idx" ON "PaymentEventLog"("status", "processedAt");
|
|
831
|
+
CREATE INDEX "PendingRegistration_checkoutGatewayAccount_checkoutSessionI_idx" ON "PendingRegistration"("checkoutGatewayAccount", "checkoutSessionId");
|
|
815
832
|
|
|
816
833
|
-- CreateIndex
|
|
817
834
|
CREATE UNIQUE INDEX "super_admin_users_email_key" ON "super_admin_users"("email");
|
|
@@ -846,15 +863,36 @@ CREATE INDEX "subscriber_tenants_subscriberId_idx" ON "subscriber_tenants"("subs
|
|
|
846
863
|
-- CreateIndex
|
|
847
864
|
CREATE INDEX "subscriber_corrections_subscriberId_correctedAt_idx" ON "subscriber_corrections"("subscriberId", "correctedAt");
|
|
848
865
|
|
|
866
|
+
-- CreateIndex
|
|
867
|
+
CREATE INDEX "subscriber_payment_methods_subscriberId_status_idx" ON "subscriber_payment_methods"("subscriberId", "status");
|
|
868
|
+
|
|
869
|
+
-- CreateIndex
|
|
870
|
+
CREATE INDEX "subscriber_payment_methods_gatewayAccount_status_idx" ON "subscriber_payment_methods"("gatewayAccount", "status");
|
|
871
|
+
|
|
872
|
+
-- CreateIndex
|
|
873
|
+
CREATE UNIQUE INDEX "subscriber_payment_methods_gatewayAccount_paymentMethodRef_key" ON "subscriber_payment_methods"("gatewayAccount", "paymentMethodRef");
|
|
874
|
+
|
|
875
|
+
-- CreateIndex
|
|
876
|
+
CREATE INDEX "subscriber_payment_method_setups_subscriberId_idx" ON "subscriber_payment_method_setups"("subscriberId");
|
|
877
|
+
|
|
878
|
+
-- CreateIndex
|
|
879
|
+
CREATE UNIQUE INDEX "subscriber_payment_method_setups_gatewayAccount_sessionRef_key" ON "subscriber_payment_method_setups"("gatewayAccount", "sessionRef");
|
|
880
|
+
|
|
881
|
+
-- CreateIndex
|
|
882
|
+
CREATE INDEX "PaymentEventLog_sessionId_idx" ON "PaymentEventLog"("sessionId");
|
|
883
|
+
|
|
884
|
+
-- CreateIndex
|
|
885
|
+
CREATE INDEX "PaymentEventLog_status_processedAt_idx" ON "PaymentEventLog"("status", "processedAt");
|
|
886
|
+
|
|
887
|
+
-- CreateIndex
|
|
888
|
+
CREATE UNIQUE INDEX "PaymentEventLog_gatewayAccount_eventId_key" ON "PaymentEventLog"("gatewayAccount", "eventId");
|
|
889
|
+
|
|
849
890
|
-- AddForeignKey
|
|
850
891
|
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_planVersionId_fkey" FOREIGN KEY ("planVersionId") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
851
892
|
|
|
852
893
|
-- AddForeignKey
|
|
853
894
|
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_pendingPlanVersionId_fkey" FOREIGN KEY ("pendingPlanVersionId") REFERENCES "plan_versions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
854
895
|
|
|
855
|
-
-- AddForeignKey
|
|
856
|
-
ALTER TABLE "subscription_payment_methods" ADD CONSTRAINT "subscription_payment_methods_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
857
|
-
|
|
858
896
|
-- AddForeignKey
|
|
859
897
|
ALTER TABLE "promo_code_redemptions" ADD CONSTRAINT "promo_code_redemptions_promoCodeId_fkey" FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
860
898
|
|
|
@@ -891,6 +929,12 @@ ALTER TABLE "subscriber_tenants" ADD CONSTRAINT "subscriber_tenants_subscriberId
|
|
|
891
929
|
-- AddForeignKey
|
|
892
930
|
ALTER TABLE "subscriber_corrections" ADD CONSTRAINT "subscriber_corrections_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
893
931
|
|
|
932
|
+
-- AddForeignKey
|
|
933
|
+
ALTER TABLE "subscriber_payment_methods" ADD CONSTRAINT "subscriber_payment_methods_subscriberId_fkey" FOREIGN KEY ("subscriberId") REFERENCES "subscribers"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
934
|
+
|
|
935
|
+
-- AddForeignKey
|
|
936
|
+
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
|
+
|
|
894
938
|
-- =============================================================================
|
|
895
939
|
-- SaaSiCat — normative PostgreSQL constraints the Prisma DSL cannot express.
|
|
896
940
|
-- =============================================================================
|
|
@@ -950,6 +994,29 @@ CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_tenant
|
|
|
950
994
|
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_tenants_live_per_subscriber
|
|
951
995
|
ON subscriber_tenants ("subscriberId") WHERE "unlinkedAt" IS NULL;
|
|
952
996
|
|
|
997
|
+
-- A subscriber has at most ONE payment method in use. The one a newer payment
|
|
998
|
+
-- method replaced keeps its row as `REPLACED`, so it does not count here.
|
|
999
|
+
CREATE UNIQUE INDEX IF NOT EXISTS subscriber_payment_methods_active_per_subscriber
|
|
1000
|
+
ON subscriber_payment_methods ("subscriberId") WHERE "status" = 'ACTIVE';
|
|
1001
|
+
|
|
1002
|
+
-- A gateway session is confirmed ONCE.
|
|
1003
|
+
--
|
|
1004
|
+
-- A gateway delivers every event at least once, and it may report one session
|
|
1005
|
+
-- through more than one event — a completed form and the payment method behind
|
|
1006
|
+
-- it can arrive as two. `eventId` tells those apart, so both would be claimed,
|
|
1007
|
+
-- and both would record the session's payment method: for a sign-up that means
|
|
1008
|
+
-- a second tenant for one registration.
|
|
1009
|
+
--
|
|
1010
|
+
-- Both adapters claim with `ON CONFLICT DO NOTHING` and no conflict target, so
|
|
1011
|
+
-- this index turns the second confirmation into no row rather than an error,
|
|
1012
|
+
-- and the caller reads it as the duplicate it is. Partial on the kind, because
|
|
1013
|
+
-- a failed setup and an event SaaSiCat does not act on say nothing about the
|
|
1014
|
+
-- session being confirmed. A claim carrying no session does not collide:
|
|
1015
|
+
-- PostgreSQL counts nulls as distinct in a unique index.
|
|
1016
|
+
CREATE UNIQUE INDEX IF NOT EXISTS payment_event_log_confirmation_per_session
|
|
1017
|
+
ON "PaymentEventLog" ("gatewayAccount", "sessionId")
|
|
1018
|
+
WHERE "status" = 'payment-method-confirmed';
|
|
1019
|
+
|
|
953
1020
|
-- Customer numbers count from 10001, so a number has five digits up to 99999
|
|
954
1021
|
-- and none reads as a count of subscribers. Two statements: the first makes
|
|
955
1022
|
-- 10001 where the sequence starts over, which a restart of its identity reads,
|