@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.
- package/admin-api.openapi.yaml +4 -1
- package/package.json +1 -1
- package/prisma-fragments/02-promo-code.prisma +38 -0
- package/prisma-fragments/14-payments.prisma +8 -0
- package/prisma-fragments/README.md +17 -17
- package/schemas/admin-manifest.schema.json +1 -1
- package/schemas/promo-code.schema.json +7 -1
- package/sql/1.0-a-promo-slot-is-held-through-checkout.postgres.sql +69 -0
- package/sql/reference-schema.postgres.sql +25 -0
package/admin-api.openapi.yaml
CHANGED
|
@@ -29,7 +29,7 @@ openapi: 3.1.0
|
|
|
29
29
|
|
|
30
30
|
info:
|
|
31
31
|
title: SaaS Platform SuperAdmin API
|
|
32
|
-
version: 1.0.0-rc.
|
|
32
|
+
version: 1.0.0-rc.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
|
@@ -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": "
|
|
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
|
|