@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.
@@ -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.16
32
+ version: 1.0.0-rc.17
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.16",
3
+ "version": "1.0.0-rc.17",
4
4
  "description": "Language-neutral spec of the SaaS platform: JSON Schemas, OpenAPI contract, Prisma fragments, acceptance scenarios.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -1,5 +1,5 @@
1
1
  // =============================================================================
2
- // SaaS-Platform Prisma fragment: Subscription + Payment method
2
+ // SaaS-Platform Prisma fragment: Subscription + CheckoutOffer
3
3
  // =============================================================================
4
4
  //
5
5
  // REFERENCE SNIPPET — not a standalone schema. Consumers copy
@@ -39,14 +39,6 @@ enum SubscriptionStatus {
39
39
  PENDING_SALES
40
40
  }
41
41
 
42
- enum SubscriptionPaymentType {
43
- CARD
44
- SEPA
45
- PAYPAL
46
- KLARNA
47
- INVOICE
48
- }
49
-
50
42
  // -----------------------------------------------------------------------------
51
43
  // Subscription — one per Tenant. Binds to PlanVersion (see 03-plan-versions).
52
44
  // -----------------------------------------------------------------------------
@@ -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 + PaymentEventLog
2
+ // SaaS Platform Prisma fragment: PendingRegistration
3
3
  // =============================================================================
4
4
  //
5
5
  // REFERENCE SNIPPET — see 01-subscription.prisma for conventions.
@@ -7,8 +7,9 @@
7
7
  // Persistence of the multi-step registration and onboarding flow
8
8
  // (`PendingRegistrationService`). A PendingRegistration holds the
9
9
  // intermediate state between step 1 (capturing sign-up data) and the final
10
- // activation (step 4: payment). Only after successful payment does it become
11
- // User + Tenant + Subscription until then the record deliberately stays
10
+ // activation (step 4: billing address and payment method). Only once the
11
+ // payment gateway confirmed the payment method does it become User + Tenant +
12
+ // Subscriber + Subscription — until then the record deliberately stays
12
13
  // without a foreign key to consumer models.
13
14
  //
14
15
  // Unlike the other fragments, WITHOUT `@@map`: the model was adopted back from
@@ -16,12 +17,14 @@
16
17
  // default names — a subsequent `@@map` would only force a table rename.
17
18
  //
18
19
  // Contract: @saasicat/core (src/registration.types.ts) (PendingRegistration,
19
- // PendingRegistrationRepository, PaymentEventLog).
20
+ // PendingRegistrationRepository). The gateway events a sign-up's
21
+ // confirmation is claimed in are `PaymentEventLog`, in
22
+ // 14-payments.prisma.
20
23
  // Service logic: @saasicat/nest/registration (PendingRegistrationService).
21
24
 
22
25
  // Multi-step registration flow: status of a PendingRegistration
23
26
  // between sign-up-data capture (step 1) and final activation
24
- // after payment (step 4).
27
+ // once the payment method is confirmed (step 4).
25
28
  enum RegistrationStatus {
26
29
  PENDING_EMAIL_VERIFICATION
27
30
  EMAIL_VERIFIED
@@ -64,11 +67,29 @@ model PendingRegistration {
64
67
  // - billingCycle: 'MONTHLY' | 'YEARLY' — determines the price calculation.
65
68
  // - appliedPromoCode: last-applied code (plaintext, only for UI display;
66
69
  // validation runs fresh every time via PromoCodesService).
67
- configJson Json?
68
- billingCycle String?
69
- appliedPromoCode String?
70
- checkoutSessionId String?
71
- checkoutStartedAt DateTime?
70
+ configJson Json?
71
+ billingCycle String?
72
+ appliedPromoCode String?
73
+
74
+ // Billing details (step 4), which the subscriber is created with. The
75
+ // address is required before a payment method is set up; the tax
76
+ // identifiers stay optional.
77
+ addressLine1 String?
78
+ addressLine2 String?
79
+ postalCode String?
80
+ city String?
81
+ country String? // ISO 3166-1 alpha-2
82
+ vatId String?
83
+ taxNumber String?
84
+
85
+ // The payment gateway's session for the payment method. A session id is
86
+ // unique only within the gateway account that opened it, so the account
87
+ // is kept beside it; the customer the gateway created is reused when the
88
+ // step is repeated.
89
+ checkoutSessionId String?
90
+ checkoutGatewayAccount String?
91
+ gatewayCustomerRef String?
92
+ checkoutStartedAt DateTime?
72
93
 
73
94
  expiresAt DateTime
74
95
  createdAt DateTime @default(now())
@@ -76,21 +97,5 @@ model PendingRegistration {
76
97
 
77
98
  @@index([status, expiresAt])
78
99
  @@index([tenantSlug])
79
- }
80
-
81
- // PaymentEventLog — idempotency key for payment webhooks.
82
- // Prevents a doubly delivered webhook (Stripe at-least-once) from triggering
83
- // the final activation more than once. `eventId` is the provider event ID
84
- // (Stripe `event.id`), `tryClaim` uses a @unique INSERT as race protection.
85
- model PaymentEventLog {
86
- id String @id @default(cuid())
87
- eventId String @unique
88
- provider String
89
- sessionId String?
90
- status String
91
- payload Json?
92
- processedAt DateTime @default(now())
93
-
94
- @@index([sessionId])
95
- @@index([status, processedAt])
100
+ @@index([checkoutGatewayAccount, checkoutSessionId])
96
101
  }
@@ -47,9 +47,11 @@ model Subscriber {
47
47
  createdAt DateTime @default(now())
48
48
  updatedAt DateTime @updatedAt
49
49
 
50
- tenants SubscriberTenant[]
51
- corrections SubscriberCorrection[]
52
- contracts SubscriptionContract[]
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`, `SubscriptionPaymentMethod`, `CheckoutOffer` + Enums |
34
- | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeValidationLog` + Enums |
35
- | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
36
- | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
37
- | [`05-bundle.prisma`](05-bundle.prisma) | `Bundle`, `BundleVersion` |
38
- | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | Capability, feature, quota and marketing catalog models |
39
- | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
40
- | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
41
- | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration`, `PaymentEventLog` + `RegistrationStatus` |
42
- | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
- | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
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`, `subscription_payment_methods`, `checkout_offers`, `plans`,
96
- `plan_versions`, `promo_codes`, `promo_code_redemptions`,
97
- `promo_code_validation_logs`, `audit_logs`, `bundles`, `bundle_versions`,
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
- `pending_registrations`, `payment_event_logs`, `super_admin_users`,
102
- `super_admin_mfa`, `subscription_bundles`, `subscribers`, `subscriber_tenants`,
103
- `subscriber_corrections`.
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
- -- Safe to run again: the block runs only while the column is missing. Rows
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
- CREATE UNIQUE INDEX IF NOT EXISTS "settings_changes_seq_key" ON "settings_changes"("seq");
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
- -- Safe to run again: both tables and the index are created only where they are
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 UNIQUE INDEX "PaymentEventLog_eventId_key" ON "PaymentEventLog"("eventId");
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,