@saasicat/spec 1.0.0-rc.20 → 1.0.0-rc.21

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.20
32
+ version: 1.0.0-rc.21
33
33
  description: |
34
34
  Read and write operations for platform administration:
35
35
  tenants, users, subscriptions, promo codes, audit log,
@@ -1979,6 +1979,9 @@ components:
1979
1979
  validUntil: { type: string, format: date-time, nullable: true }
1980
1980
  maxRedemptions: { type: integer, nullable: true }
1981
1981
  redemptionsCount: { type: integer }
1982
+ heldCount:
1983
+ type: integer
1984
+ description: Slots held for checkouts that started and have not concluded.
1982
1985
  appliesToPlans:
1983
1986
  type: array
1984
1987
  items: { type: string }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/spec",
3
- "version": "1.0.0-rc.20",
3
+ "version": "1.0.0-rc.21",
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",
@@ -56,6 +56,10 @@ model PromoCode {
56
56
  // Availability-relevant — counts only redemptions with status=ACTIVE.
57
57
  // Incremented atomically in the redeem() path, decremented on trial cancellation.
58
58
  redemptionsCount Int @default(0)
59
+ // Slots kept for checkouts that have started and not concluded (see
60
+ // PromoCodeHold). A code has a free slot while redemptionsCount + heldCount
61
+ // stays below maxRedemptions; the status says nothing about holds.
62
+ heldCount Int @default(0)
59
63
 
60
64
  // Plan whitelist as String[] — values are plan IDs from plans.yaml.
61
65
  // Consumers using a Postgres enum can cast the field locally.
@@ -86,6 +90,7 @@ model PromoCode {
86
90
 
87
91
  redemptions PromoCodeRedemption[]
88
92
  validationLogs PromoCodeValidationLog[]
93
+ holds PromoCodeHold[]
89
94
 
90
95
  @@index([status, validFrom, validUntil])
91
96
  @@index([campaignTag])
@@ -126,6 +131,39 @@ model PromoCodeRedemption {
126
131
  @@map("promo_code_redemptions")
127
132
  }
128
133
 
134
+ // -----------------------------------------------------------------------------
135
+ // PromoCodeHold — a slot of a code kept for a checkout from its start, so that
136
+ // a customer who reaches the payment form keeps the code they started with.
137
+ // For a sign-up, `expiresAt` is the last moment a confirmation of its payment
138
+ // form can arrive, not the checkout's lifetime. It counts in
139
+ // PromoCode.heldCount, and it ends in one of three ways, each deleting the row
140
+ // and giving the count back: the conclusion turns it into the redemption, the
141
+ // checkout offer's code changes, or `expiresAt` passes. It is a reservation
142
+ // rather than a record — what a customer was granted is the
143
+ // PromoCodeRedemption.
144
+ // -----------------------------------------------------------------------------
145
+
146
+ model PromoCodeHold {
147
+ id String @id @default(uuid())
148
+ promoCodeId String
149
+ // Not a foreign key: an offer deleted while it holds a slot leaves the hold
150
+ // to expire, which gives the slot back. A cascade would delete the row and
151
+ // leave heldCount counting it.
152
+ checkoutOfferId String @unique
153
+ expiresAt DateTime
154
+ // The id of the transaction a conclusion runs on (`txid_current()`), set on
155
+ // that transaction and gone before it commits: the redemption running on
156
+ // the same transaction finds its slot by it, and no other one can.
157
+ handedOverTx BigInt?
158
+ createdAt DateTime @default(now())
159
+
160
+ promoCode PromoCode @relation(fields: [promoCodeId], references: [id])
161
+
162
+ @@index([promoCodeId, expiresAt])
163
+ @@index([expiresAt])
164
+ @@map("promo_code_holds")
165
+ }
166
+
129
167
  // -----------------------------------------------------------------------------
130
168
  // PromoCodeValidationLog — audit trail of all validation attempts
131
169
  // (including failed NOT_FOUND/EXPIRED), for anti-abuse analysis.
@@ -15,6 +15,14 @@
15
15
  // payment method, a partial unique index in sql/constraints.postgres.sql;
16
16
  // the one a newer payment method replaced stays as `REPLACED`.
17
17
  //
18
+ // Within an account a reference belongs to exactly one subscriber, which is
19
+ // what `@@unique([gatewayAccount, paymentMethodRef])` below holds. The port
20
+ // draws the same boundary for reading: a confirmation carrying a reference
21
+ // another subscriber holds is refused rather than recorded, and a read by
22
+ // reference names the subscriber it asks about. Both are reached on the
23
+ // gateway's callback, which arrives without a session — an installation that
24
+ // keeps its tenants apart with a policy on this table lifts it there.
25
+ //
18
26
  // Contract: @saasicat/core (SubscriberPaymentMethodRepository, PaymentEventLog).
19
27
  // Both are needed wherever payment methods are taken — self-registration or
20
28
  // not — because every gateway callback is claimed in `PaymentEventLog`.
@@ -28,22 +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`, `CheckoutOffer` + Enums |
34
- | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeValidationLog` + Enums |
35
- | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
36
- | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
37
- | [`05-bundle.prisma`](05-bundle.prisma) | `Bundle`, `BundleVersion` |
38
- | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | Capability, feature, quota and marketing catalog models |
39
- | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
40
- | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
41
- | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration` + `RegistrationStatus` |
42
- | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
- | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
44
- | [`12-applied-settings.prisma`](12-applied-settings.prisma) | `AppliedSettings`, `SettingsChange` |
45
- | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection` |
46
- | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
31
+ | File | Models |
32
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
33
+ | [`01-subscription.prisma`](01-subscription.prisma) | `Subscription`, `CheckoutOffer` + Enums |
34
+ | [`02-promo-code.prisma`](02-promo-code.prisma) | `PromoCode`, `PromoCodeRedemption`, `PromoCodeHold`, `PromoCodeValidationLog` + Enums |
35
+ | [`03-plan-versions.prisma`](03-plan-versions.prisma) | `Plan`, `PlanVersion` |
36
+ | [`04-audit-log.prisma`](04-audit-log.prisma) | `AuditLog` |
37
+ | [`05-bundle.prisma`](05-bundle.prisma) | `Bundle`, `BundleVersion` |
38
+ | [`06-catalog-entries.prisma`](06-catalog-entries.prisma) | Capability, feature, quota and marketing catalog models |
39
+ | [`07-promotion.prisma`](07-promotion.prisma) | `Promotion` |
40
+ | [`08-subscription-contract.prisma`](08-subscription-contract.prisma) | `SubscriptionContract`, `ContractLineItem` |
41
+ | [`09-pending-registration.prisma`](09-pending-registration.prisma) | `PendingRegistration` + `RegistrationStatus` |
42
+ | [`10-super-admin.prisma`](10-super-admin.prisma) | `SuperAdminUser`, `SuperAdminMfa` |
43
+ | [`11-subscription-bundle.prisma`](11-subscription-bundle.prisma) | `SubscriptionBundle` |
44
+ | [`12-applied-settings.prisma`](12-applied-settings.prisma) | `AppliedSettings`, `SettingsChange` |
45
+ | [`13-subscriber.prisma`](13-subscriber.prisma) | `Subscriber`, `SubscriberTenant`, `SubscriberCorrection` |
46
+ | [`14-payments.prisma`](14-payments.prisma) | `SubscriberPaymentMethod`, `SubscriberPaymentMethodSetup`, `PaymentEventLog` |
47
47
 
48
48
  ## How the consumer uses the fragments
49
49
 
@@ -94,7 +94,7 @@ from ever being deleted.
94
94
  ### 3. Table names (`@@map`) are canonical
95
95
 
96
96
  `subscriptions`, `checkout_offers`, `plans`, `plan_versions`, `promo_codes`,
97
- `promo_code_redemptions`, `promo_code_validation_logs`, `audit_logs`, `bundles`,
97
+ `promo_code_redemptions`, `promo_code_holds`, `promo_code_validation_logs`, `audit_logs`, `bundles`,
98
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`,
@@ -76,7 +76,7 @@
76
76
  "properties": {
77
77
  "source": {
78
78
  "type": "string",
79
- "description": "Path to the source, e.g. 'config/saas.yaml'"
79
+ "description": "Where the plans and features were read from: 'database' for the ones an operator publishes, 'given' for a catalogue passed in at start"
80
80
  },
81
81
  "hash": { "type": "string", "pattern": "^sha256-[A-Za-z0-9+/=_-]{32,}$" },
82
82
  "currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
@@ -129,7 +129,8 @@
129
129
  "value",
130
130
  "durationType",
131
131
  "status",
132
- "redemptionsCount"
132
+ "redemptionsCount",
133
+ "heldCount"
133
134
  ],
134
135
  "additionalProperties": false,
135
136
  "properties": {
@@ -143,6 +144,11 @@
143
144
  "validUntil": { "type": ["string", "null"], "format": "date-time" },
144
145
  "maxRedemptions": { "type": ["integer", "null"], "minimum": 1 },
145
146
  "redemptionsCount": { "type": "integer", "minimum": 0 },
147
+ "heldCount": {
148
+ "type": "integer",
149
+ "minimum": 0,
150
+ "description": "Slots held for checkouts that started and have not concluded. A code has a free slot while redemptionsCount + heldCount is below maxRedemptions."
151
+ },
146
152
  "appliesToPlans": {
147
153
  "type": "array",
148
154
  "items": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]+$" }
@@ -0,0 +1,69 @@
1
+ -- =============================================================================
2
+ -- SaaSiCat 1.0 — a promo code's slot is held from the start of a checkout.
3
+ -- =============================================================================
4
+ --
5
+ -- A sign-up that starts its checkout with a promo code keeps a slot of that
6
+ -- code until the checkout concludes, or until a confirmation of its payment
7
+ -- form can no longer arrive, so that a customer who reaches the payment form is
8
+ -- not refused afterwards because somebody else took the code's last redemption
9
+ -- in between. Run it the same way as the other files in
10
+ -- this directory, before `db push` where you use one:
11
+ --
12
+ -- psql "$DATABASE_URL" -f 1.0-a-promo-slot-is-held-through-checkout.postgres.sql
13
+ --
14
+ -- What it touches, in one transaction, and only where `promo_codes` is there:
15
+ --
16
+ -- 1. `promo_codes` — gains `heldCount`, the slots held for checkouts, next to
17
+ -- `redemptionsCount`. Every existing code starts at 0: no checkout held a
18
+ -- slot before this file.
19
+ -- 2. `promo_code_holds` — created with its indexes and its foreign key to
20
+ -- `promo_codes`. One row per checkout offer holding a slot; the row is
21
+ -- deleted when the hold ends, so the table stays small.
22
+ --
23
+ -- Safe to run again: the column, the table and the indexes are created only
24
+ -- where they are missing, and the foreign key only where its name is not taken.
25
+ -- On a database created from `reference-schema.postgres.sql` the whole file does
26
+ -- nothing at all.
27
+
28
+ BEGIN;
29
+
30
+ DO $$
31
+ BEGIN
32
+ IF to_regclass('promo_codes') IS NULL THEN
33
+ RAISE NOTICE 'promo_codes is not present — nothing to do; this installation takes no promo codes.';
34
+ RETURN;
35
+ END IF;
36
+
37
+ ALTER TABLE "promo_codes" ADD COLUMN IF NOT EXISTS "heldCount" INTEGER NOT NULL DEFAULT 0;
38
+
39
+ CREATE TABLE IF NOT EXISTS "promo_code_holds" (
40
+ "id" TEXT NOT NULL,
41
+ "promoCodeId" TEXT NOT NULL,
42
+ "checkoutOfferId" TEXT NOT NULL,
43
+ "expiresAt" TIMESTAMP(3) NOT NULL,
44
+ "handedOverTx" BIGINT,
45
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
46
+
47
+ CONSTRAINT "promo_code_holds_pkey" PRIMARY KEY ("id")
48
+ );
49
+ CREATE UNIQUE INDEX IF NOT EXISTS "promo_code_holds_checkoutOfferId_key"
50
+ ON "promo_code_holds"("checkoutOfferId");
51
+ CREATE INDEX IF NOT EXISTS "promo_code_holds_promoCodeId_expiresAt_idx"
52
+ ON "promo_code_holds"("promoCodeId", "expiresAt");
53
+ CREATE INDEX IF NOT EXISTS "promo_code_holds_expiresAt_idx"
54
+ ON "promo_code_holds"("expiresAt");
55
+
56
+ -- `ADD CONSTRAINT` has no `IF NOT EXISTS`.
57
+ IF NOT EXISTS (
58
+ SELECT 1 FROM pg_constraint
59
+ WHERE conrelid = to_regclass('promo_code_holds')
60
+ AND conname = 'promo_code_holds_promoCodeId_fkey'
61
+ ) THEN
62
+ ALTER TABLE "promo_code_holds"
63
+ ADD CONSTRAINT "promo_code_holds_promoCodeId_fkey"
64
+ FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id")
65
+ ON DELETE RESTRICT ON UPDATE CASCADE;
66
+ END IF;
67
+ END $$;
68
+
69
+ COMMIT;
@@ -128,6 +128,7 @@ CREATE TABLE "promo_codes" (
128
128
  "validUntil" TIMESTAMP(3),
129
129
  "maxRedemptions" INTEGER,
130
130
  "redemptionsCount" INTEGER NOT NULL DEFAULT 0,
131
+ "heldCount" INTEGER NOT NULL DEFAULT 0,
131
132
  "appliesToPlans" TEXT[],
132
133
  "appliesToBilling" "BillingCycle",
133
134
  "firstTimeCustomersOnly" BOOLEAN NOT NULL DEFAULT true,
@@ -164,6 +165,18 @@ CREATE TABLE "promo_code_redemptions" (
164
165
  CONSTRAINT "promo_code_redemptions_pkey" PRIMARY KEY ("id")
165
166
  );
166
167
 
168
+ -- CreateTable
169
+ CREATE TABLE "promo_code_holds" (
170
+ "id" TEXT NOT NULL,
171
+ "promoCodeId" TEXT NOT NULL,
172
+ "checkoutOfferId" TEXT NOT NULL,
173
+ "expiresAt" TIMESTAMP(3) NOT NULL,
174
+ "handedOverTx" BIGINT,
175
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
176
+
177
+ CONSTRAINT "promo_code_holds_pkey" PRIMARY KEY ("id")
178
+ );
179
+
167
180
  -- CreateTable
168
181
  CREATE TABLE "promo_code_validation_logs" (
169
182
  "id" TEXT NOT NULL,
@@ -710,6 +723,15 @@ CREATE INDEX "promo_code_redemptions_promoCodeId_status_idx" ON "promo_code_rede
710
723
  -- CreateIndex
711
724
  CREATE INDEX "promo_code_redemptions_startsAt_endsAt_idx" ON "promo_code_redemptions"("startsAt", "endsAt");
712
725
 
726
+ -- CreateIndex
727
+ CREATE UNIQUE INDEX "promo_code_holds_checkoutOfferId_key" ON "promo_code_holds"("checkoutOfferId");
728
+
729
+ -- CreateIndex
730
+ CREATE INDEX "promo_code_holds_promoCodeId_expiresAt_idx" ON "promo_code_holds"("promoCodeId", "expiresAt");
731
+
732
+ -- CreateIndex
733
+ CREATE INDEX "promo_code_holds_expiresAt_idx" ON "promo_code_holds"("expiresAt");
734
+
713
735
  -- CreateIndex
714
736
  CREATE INDEX "promo_code_validation_logs_codeAttempt_createdAt_idx" ON "promo_code_validation_logs"("codeAttempt", "createdAt");
715
737
 
@@ -899,6 +921,9 @@ ALTER TABLE "promo_code_redemptions" ADD CONSTRAINT "promo_code_redemptions_prom
899
921
  -- AddForeignKey
900
922
  ALTER TABLE "promo_code_redemptions" ADD CONSTRAINT "promo_code_redemptions_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
901
923
 
924
+ -- AddForeignKey
925
+ ALTER TABLE "promo_code_holds" ADD CONSTRAINT "promo_code_holds_promoCodeId_fkey" FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
926
+
902
927
  -- AddForeignKey
903
928
  ALTER TABLE "promo_code_validation_logs" ADD CONSTRAINT "promo_code_validation_logs_promoCodeId_fkey" FOREIGN KEY ("promoCodeId") REFERENCES "promo_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
904
929