mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
chore(saas): remove unused Flyway migration system (#7100)
## What this PR does Removes the **Flyway** migration system from the SaaS build: - drops `flyway-core` + `flyway-database-postgresql` from `app/saas/build.gradle` - deletes all `Vxx__*.sql` files under `app/saas/src/main/resources/db/migration/` - removes the `spring.flyway.*` config from `application-saas.properties` - clears the now-inert `SPRING_FLYWAY_ENABLED` override and stale Flyway comments in `testing/compose/docker-compose-saas.yml` `ddl-auto` is **left on `update`** (unchanged) — that's a separate decision (see "Not in this PR"). ## Why — Flyway never actually ran anywhere From a full schema-management review (`notes/FLYWAY_MIGRATION_REVIEW.md`), verified against the live databases: - **Prod & dev (v3):** no `flyway_schema_history` table exists in any schema → Flyway has never executed. Schema is authored by the Supabase migrations in `Stirling-PDF-SaaS` and applied by that repo's **GitHub integration** (merge to `main` → prod). - **Tests:** the saas module has zero `@SpringBootTest`; the only real-DB integration tests (in `proprietary`) use `ddl-auto=create-drop` and don't have Flyway on the classpath. - **The mock-DB harness** (`testing/compose/docker-compose-saas.yml`, PAYG cucumber) *explicitly disabled* Flyway, because the `Vxx` migrations can't run against a clean Postgres — they assume Supabase has already provisioned `users`/`teams` (`V2` ALTERs `users`, `V5` references `teams`). So Flyway was dead weight, and its 4 duplicate versions (`V25/V26/V30/V31`) were a latent trap: re-enabling it would crash boot on the collision. Everything it contained (schema + seeds like the default pricing policy) is already mirrored by the Supabase migrations, and by `saas-seed.sql` for the cucumber stack. ## ⚠️ Required follow-up (item #1) — capture the Flyway-only tables into Supabase migrations **This is documentation of the next step, not done in this PR.** Seven tables were defined in Flyway with **no matching Supabase migration**. They exist in prod today only because `ddl-auto=update` created them from their entities. Before `ddl-auto` is ever tightened to `validate` (see #3 below), and so any fresh Supabase branch is complete, they must be added as Supabase migrations in `Stirling-PDF-SaaS/supabase/migrations/`. **Capture (CREATE) — 6 live tables** (definitions are visible in the deleted files in this PR's diff): | Table | Source (deleted here) | Backing entity | |---|---|---| | `resource_grants` | `V25__resource_grants.sql` | `ResourceGrant` | | `integration_configs` | `V26__integration_configs.sql` | `IntegrationConfig` | | `policy_sources` | `V22__policy_engine_tables.sql` | `SourceEntity` | | `policy_source_doc_counts` | `V23__policy_source_doc_counts.sql` | `SourceDocCountEntity` | | `policy_source_doc_totals` | `V23__policy_source_doc_counts.sql` | `SourceDocTotalEntity` | | `saas_user_extensions` | `V9__saas_user_team_extensions.sql` | `SaasUserExtensions` | Write them as `CREATE TABLE IF NOT EXISTS stirling_pdf.<name> (...)` (idempotent — no-op against the existing prod/v3 tables). Preserve column types/defaults/constraints from the deleted `Vxx` files. **Drop (do NOT recreate) — 1 orphaned table:** - `classification_labels` — created by `V30` and dropped by `V39` within Flyway; its `ClassificationLabel` is now a plain `record`, not a JPA entity. It lingers in prod only because Flyway's `V39` drop never ran. The follow-up should emit a `DROP TABLE IF EXISTS stirling_pdf.classification_labels` (mirroring `V39`'s intent), after confirming nothing reads it. ## Not in this PR (deliberately) - **#3 — flip saas `ddl-auto` `update` → `validate`.** Held pending team confirmation; it has boot-risk and should be gated by a CI "validate-boot against a fresh Supabase branch" first. Self-hosted stays on `update` regardless. - **#4 — `billing_subscriptions` split-brain** (prod `public` has 23,164 rows, `stirling_pdf` has 0, Java reads the empty one). Tracked separately. ## Verification - `:saas:compileJava` succeeds with Flyway removed (no code imports `org.flywaydb.*`). - No test or ArchUnit rule references Flyway or the migration files. - No runtime/data impact: Flyway never ran against any live database. --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
co-authored by
James Brunton
parent
401894a065
commit
f60a75c253
@@ -12,8 +12,5 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
|
||||
api 'org.flywaydb:flyway-core'
|
||||
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
||||
|
||||
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
|
||||
}
|
||||
|
||||
@@ -14,15 +14,13 @@ spring.datasource.username=${SAAS_DB_USERNAME:postgres}
|
||||
spring.datasource.password=${SAAS_DB_PASSWORD:}
|
||||
|
||||
# ---------- DB schema / migrations ----------
|
||||
# Schema is authored by the Supabase migrations in the Stirling-PDF-SaaS repo and applied to
|
||||
# Supabase by its GitHub integration (merge to main -> prod). The Java side only pins the target
|
||||
# schema and lets Hibernate reconcile the entity tables on boot.
|
||||
spring.jpa.properties.hibernate.default_schema=stirling_pdf
|
||||
spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.baseline-on-migrate=true
|
||||
spring.flyway.locations=classpath:db/migration,classpath:db/migration/saas
|
||||
spring.flyway.schemas=stirling_pdf
|
||||
spring.flyway.default-schema=stirling_pdf
|
||||
|
||||
# ---------- Supabase JWT auth ----------
|
||||
# Required: set SAAS_DB_PROJECT_REF via env.
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
-- PAYG data model: pricing policy, processing jobs + lineage, wallet ledger, wallet policy,
|
||||
-- entitlement snapshots, shadow-mode comparison rows, plus a payg_team_extensions sidecar table
|
||||
-- carrying team-level PAYG fields, and a cap_units column on team_memberships.
|
||||
--
|
||||
-- Sidecar pattern (mirrors saas_team_extensions): PAYG-only team fields don't sit directly on
|
||||
-- `teams`, so OSS deployments running Hibernate ddl-auto=update against the proprietary Team
|
||||
-- entity never see PAYG columns they don't have entities for.
|
||||
--
|
||||
-- Everything is purely additive. No existing rows are modified, no columns are dropped.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. pricing_policy — versioned economic config (units, lifecycle metadata).
|
||||
-- step_limits and stripe_price_ids live on normalised child tables below — typed columns, no
|
||||
-- JSON parsing, queryable directly.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy (
|
||||
policy_id BIGSERIAL PRIMARY KEY,
|
||||
version VARCHAR(32) NOT NULL UNIQUE,
|
||||
effective_from TIMESTAMP NOT NULL,
|
||||
effective_to TIMESTAMP,
|
||||
doc_pages_per_unit INTEGER NOT NULL,
|
||||
doc_bytes_per_unit BIGINT NOT NULL,
|
||||
min_charge_units INTEGER NOT NULL DEFAULT 1,
|
||||
file_unit_cap INTEGER NOT NULL DEFAULT 1000,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notes TEXT,
|
||||
created_by VARCHAR(255),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_pricing_policy_default
|
||||
ON pricing_policy (is_default) WHERE is_default = TRUE;
|
||||
|
||||
-- Max steps allowed per process for each caller surface (JobSource).
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy_step_limit (
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE,
|
||||
job_source VARCHAR(32) NOT NULL,
|
||||
step_limit INTEGER NOT NULL,
|
||||
PRIMARY KEY (policy_id, job_source)
|
||||
);
|
||||
|
||||
-- Stripe Price IDs this policy resolves to, one per supported currency. Currency itself isn't
|
||||
-- stored here — it lives on stripe.prices.currency and is looked up via Sync Engine when picking
|
||||
-- the right Price for a customer's subscription. All prices in one policy must share the same
|
||||
-- Billing Meter and the same first-tier upper bound in units (deploy-time CI check).
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy_stripe_price (
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE,
|
||||
stripe_price_id VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (policy_id, stripe_price_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. payg_team_extensions — sidecar carrying PAYG-only team fields. 1:1 with teams via shared PK.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payg_team_extensions (
|
||||
team_id BIGINT PRIMARY KEY REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
pricing_policy_id BIGINT REFERENCES pricing_policy(policy_id),
|
||||
stripe_customer_id VARCHAR(128) UNIQUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN payg_team_extensions.pricing_policy_id IS
|
||||
'Override policy for this team. NULL means use the row in pricing_policy with is_default=TRUE.';
|
||||
COMMENT ON COLUMN payg_team_extensions.stripe_customer_id IS
|
||||
'Stripe customer id for this team. Eager-created so every team has billing identity on file.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. team_memberships column addition: optional per-member sub-cap. Lives directly on the table
|
||||
-- because team_memberships is already a SaaS-only table.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE team_memberships
|
||||
ADD COLUMN IF NOT EXISTS cap_units BIGINT;
|
||||
COMMENT ON COLUMN team_memberships.cap_units IS
|
||||
'Per-period spend cap for this member inside their team wallet, in doc units. NULL = no member-level cap.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. processing_job — one billable process; step_count and last_step_at track the workflow window.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS processing_job (
|
||||
job_id UUID PRIMARY KEY,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
owner_team_id BIGINT,
|
||||
process_type VARCHAR(32) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
document_fingerprint VARCHAR(64),
|
||||
doc_units INTEGER NOT NULL DEFAULT 0,
|
||||
step_count INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_step_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id),
|
||||
charged_units INTEGER,
|
||||
charged_cents INTEGER,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
idempotency_key VARCHAR(128) UNIQUE,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_owner_open
|
||||
ON processing_job (owner_user_id, status) WHERE status = 'OPEN';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_last_step
|
||||
ON processing_job (status, last_step_at) WHERE status = 'OPEN';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. processing_job_step — per-tool-call audit within a job.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS processing_job_step (
|
||||
step_id BIGSERIAL PRIMARY KEY,
|
||||
job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE,
|
||||
tool_id VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
input_pages INTEGER,
|
||||
input_bytes BIGINT,
|
||||
error_code VARCHAR(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_step_job
|
||||
ON processing_job_step (job_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 6. job_artifact_hash — per-step input/output content hashes used by the lineage detector.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- content_hash holds "type:value" signature keys; VARCHAR(128) fits SHA-256 and future schemes.
|
||||
CREATE TABLE IF NOT EXISTS job_artifact_hash (
|
||||
job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE,
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
kind VARCHAR(8) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (job_id, content_hash, kind)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artifact_hash_lookup
|
||||
ON job_artifact_hash (content_hash, created_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 7. wallet_ledger — append-only signed-amount ledger keyed on team_id.
|
||||
-- amount_units is INTEGER (per-row delta, always small); cap and rollup columns are BIGINT
|
||||
-- because they accumulate across a billing period.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_ledger (
|
||||
entry_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
actor_user_id BIGINT,
|
||||
entry_type VARCHAR(32) NOT NULL,
|
||||
bucket VARCHAR(16) NOT NULL,
|
||||
amount_units INTEGER NOT NULL,
|
||||
reference_type VARCHAR(32) NOT NULL,
|
||||
reference_id VARCHAR(128) NOT NULL,
|
||||
policy_id BIGINT,
|
||||
stripe_event_id VARCHAR(128),
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team
|
||||
ON wallet_ledger (team_id, occurred_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_actor
|
||||
ON wallet_ledger (team_id, actor_user_id, occurred_at) WHERE actor_user_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_ref
|
||||
ON wallet_ledger (reference_type, reference_id, entry_type, bucket);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_stripe_event
|
||||
ON wallet_ledger (stripe_event_id) WHERE stripe_event_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 8. wallet_policy — per-team charging engine, cap, degradation rules, lineage strategy.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_policy (
|
||||
policy_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
engine VARCHAR(16) NOT NULL DEFAULT 'LEGACY',
|
||||
cap_period VARCHAR(16) NOT NULL DEFAULT 'CALENDAR_MONTH',
|
||||
cap_units BIGINT,
|
||||
-- Customer's money intent ("I want $50/month"); the currency comes from the team's Stripe
|
||||
-- customer at recompute time, not stored separately here.
|
||||
cap_source_money BIGINT,
|
||||
warn_at_pct INTEGER NOT NULL DEFAULT 80,
|
||||
degrade_at_pct INTEGER NOT NULL DEFAULT 100,
|
||||
degraded_feature_set VARCHAR(32) NOT NULL DEFAULT 'MINIMAL',
|
||||
auto_group_strategy VARCHAR(16) NOT NULL DEFAULT 'AUTO',
|
||||
notification_emails JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 9. wallet_entitlement_snapshot — hot-path state for the entitlement guard.
|
||||
-- user_id = 0 is the team-wide sentinel (Postgres treats NULL as not-equal-to-NULL in unique
|
||||
-- constraints, so 0 is the cleaner choice for a composite PK).
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_entitlement_snapshot (
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL DEFAULT 0,
|
||||
period_start TIMESTAMP NOT NULL,
|
||||
period_end TIMESTAMP NOT NULL,
|
||||
period_spend_units BIGINT NOT NULL DEFAULT 0,
|
||||
period_cap_units BIGINT,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'FULL',
|
||||
feature_set VARCHAR(32) NOT NULL DEFAULT 'FULL',
|
||||
enabled_gates JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
computed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (team_id, user_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 10. payg_shadow_charge — per-job legacy-vs-PAYG diff during PAYG_SHADOW engine mode.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payg_shadow_charge (
|
||||
shadow_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
job_id UUID NOT NULL,
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id),
|
||||
payg_units INTEGER NOT NULL,
|
||||
legacy_credits_charged INTEGER NOT NULL,
|
||||
diff_pct INTEGER NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_shadow_team_time
|
||||
ON payg_shadow_charge (team_id, occurred_at);
|
||||
@@ -1,37 +0,0 @@
|
||||
-- Seed the V1 default pricing policy. Idempotent — only inserts when no default row exists.
|
||||
-- Units sized so a typical 25-page / 5 MiB document is 1 unit; tune via admin endpoints once
|
||||
-- Stripe Prices are wired in production.
|
||||
--
|
||||
-- This migration is separated from V11 because V11 has already shipped to main — adding rows to
|
||||
-- it would change its Flyway checksum and break existing deployments.
|
||||
|
||||
INSERT INTO pricing_policy (
|
||||
version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
|
||||
min_charge_units, file_unit_cap, is_default, notes, created_by
|
||||
)
|
||||
SELECT
|
||||
'v1-initial', CURRENT_TIMESTAMP, 25, 5242880,
|
||||
1, 1000, TRUE,
|
||||
'V1 default seeded by V12 migration. Tune via admin once Stripe Prices are configured.',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM pricing_policy WHERE is_default = TRUE
|
||||
);
|
||||
|
||||
-- Step limits for the default policy across every JobSource. References the row inserted above
|
||||
-- via the partial unique index on is_default=TRUE.
|
||||
INSERT INTO pricing_policy_step_limit (policy_id, job_source, step_limit)
|
||||
SELECT p.policy_id, src.job_source, src.step_limit
|
||||
FROM pricing_policy p
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('WEB', 10),
|
||||
('API', 10),
|
||||
('PIPELINE', 20), -- automations get a longer chain
|
||||
('DESKTOP_APP', 10)
|
||||
) AS src(job_source, step_limit)
|
||||
WHERE p.is_default = TRUE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pricing_policy_step_limit s
|
||||
WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Refund tracking on shadow rows. Shadow models the eventual Stripe meter_event_adjustment(cancel)
|
||||
-- by flipping status from CHARGED to REFUNDED in the same request's afterCompletion when a
|
||||
-- freshly-opened process fails with 5xx on its first step.
|
||||
--
|
||||
-- Reconciliation report selects SUM(payg_units) WHERE status = 'CHARGED' to get the true net
|
||||
-- Stripe would bill.
|
||||
|
||||
ALTER TABLE payg_shadow_charge
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'CHARGED',
|
||||
ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS refund_reason VARCHAR(128);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_shadow_status_time
|
||||
ON payg_shadow_charge (status, occurred_at);
|
||||
|
||||
-- Hot-path index for findFirstByJobIdOrderByIdAsc: hit on every 5xx-first-step refund to flip
|
||||
-- the row to REFUNDED. UNIQUE because at most one shadow row exists per processing_job by
|
||||
-- construction (openProcess writes exactly one on OPENED, zero on JOINED).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_payg_shadow_job_id
|
||||
ON payg_shadow_charge (job_id);
|
||||
@@ -1,189 +0,0 @@
|
||||
-- PAYG subscription state — the column + functions that let new customers reach Stripe billing.
|
||||
--
|
||||
-- This migration is half of the Stripe/Supabase wire-up (PR-SB-1 in `notes/PAYG_DESIGN.md`
|
||||
-- revision note + `payg-stripe-supabase-plan.html`). It's strictly additive:
|
||||
-- * one new column on payg_team_extensions (payg_subscription_id)
|
||||
-- * one new column on pricing_policy (free_tier_units_per_cycle)
|
||||
-- * two RPC functions (payg_link_subscription, payg_unlink_subscription) — the only writers
|
||||
-- of subscription state, called by stripe-webhook + create-payg-team-subscription edge fns
|
||||
-- * an AFTER-INSERT trigger on teams that auto-creates the payg_team_extensions sidecar row
|
||||
-- so every new signup is PAYG-by-default
|
||||
-- * an RLS policy that lets team LEADERs (and the service role) link subscriptions
|
||||
--
|
||||
-- No behaviour change for the running app until PR-SB-4 wires PaygMeterReportingService and
|
||||
-- the free-tier gate into JobChargeService. Until then this just exposes new state for the
|
||||
-- edge functions in PR-SB-2 to write through to.
|
||||
--
|
||||
-- Design references:
|
||||
-- * notes/PAYG_DESIGN.md (revision note 2026-06-03 — "subscription presence is the gate")
|
||||
-- * payg-stripe-supabase-plan.html §3.1 — RPC functions; §3.5 — RLS policy
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. New columns
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_team_extensions
|
||||
ADD COLUMN IF NOT EXISTS payg_subscription_id VARCHAR(128) UNIQUE;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.payg_team_extensions.payg_subscription_id IS
|
||||
'Stripe subscription id (sub_xxx) for this team''s PAYG metered subscription. '
|
||||
'NULL = team has not added a card yet; engine writes shadow rows only. '
|
||||
'NOT NULL = engine posts meter events to Stripe on every billable tool call. '
|
||||
'Mutated exclusively by payg_link_subscription / payg_unlink_subscription RPC functions.';
|
||||
|
||||
ALTER TABLE stirling_pdf.pricing_policy
|
||||
ADD COLUMN IF NOT EXISTS free_tier_units_per_cycle BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units_per_cycle IS
|
||||
'Doc units a team on this policy can consume per cycle before they must add a card. '
|
||||
'Default 0 = no free tier (block immediately). The seeded default policy will set this '
|
||||
'to the launch free-tier size; the special "launch" policy used by the day-1 legacy '
|
||||
'migration script (see PAYG_DESIGN.md §3.10 revised) can override.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. RPC: payg_link_subscription
|
||||
--
|
||||
-- Called by:
|
||||
-- * supabase/functions/create-payg-team-subscription/index.ts (post-Stripe-Checkout, with
|
||||
-- either user JWT [normal path, RLS-enforced] or service-role [day-1 migration script])
|
||||
-- * supabase/functions/stripe-webhook/handlers/payg-subscription.ts on
|
||||
-- customer.subscription.created (idempotent — second invocation with same args is a no-op)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_link_subscription(
|
||||
p_team_id BIGINT,
|
||||
p_customer_id TEXT,
|
||||
p_subscription_id TEXT
|
||||
) RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE stirling_pdf.payg_team_extensions
|
||||
SET stripe_customer_id = p_customer_id,
|
||||
payg_subscription_id = p_subscription_id,
|
||||
updated_at = now()
|
||||
WHERE team_id = p_team_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'payg_team_extensions row missing for team %', p_team_id
|
||||
USING ERRCODE = 'foreign_key_violation';
|
||||
END IF;
|
||||
|
||||
INSERT INTO stirling_pdf.payg_subscription_change_log(team_id, action, subscription_id)
|
||||
VALUES (p_team_id, 'LINKED', p_subscription_id);
|
||||
END $$;
|
||||
|
||||
COMMENT ON FUNCTION stirling_pdf.payg_link_subscription(BIGINT, TEXT, TEXT) IS
|
||||
'Idempotent link of a Stripe subscription to a team. SECURITY INVOKER means RLS applies — '
|
||||
'the caller must be a LEADER of the team (or hold the service-role bypass). '
|
||||
'Writes an audit row to payg_subscription_change_log.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. RPC: payg_unlink_subscription
|
||||
--
|
||||
-- Called by stripe-webhook handlers/payg-subscription.ts on customer.subscription.deleted
|
||||
-- (after Stripe's own retries have given up). Drops the team back to free-tier-then-block.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_unlink_subscription(
|
||||
p_team_id BIGINT,
|
||||
p_reason TEXT
|
||||
) RETURNS VOID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE stirling_pdf.payg_team_extensions
|
||||
SET payg_subscription_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE team_id = p_team_id;
|
||||
-- We deliberately keep stripe_customer_id — the team may add a new card later and we'd
|
||||
-- like to reuse the existing Stripe customer record rather than create a duplicate.
|
||||
|
||||
INSERT INTO stirling_pdf.payg_subscription_change_log(team_id, action, reason)
|
||||
VALUES (p_team_id, 'UNLINKED', p_reason);
|
||||
END $$;
|
||||
|
||||
COMMENT ON FUNCTION stirling_pdf.payg_unlink_subscription(BIGINT, TEXT) IS
|
||||
'Drops the team back to free-tier-then-block derived state. Reason is logged for audit '
|
||||
'(typically subscription_deleted | admin | card_removed).';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. Auto-create payg_team_extensions row when a team is created
|
||||
--
|
||||
-- Every new signup gets a payg_team_extensions row with NULL pricing_policy_id (which the
|
||||
-- backend's PricingPolicyService resolves to the default policy). The free-tier gate kicks in
|
||||
-- from the very first tool call.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO stirling_pdf.payg_team_extensions(team_id)
|
||||
VALUES (NEW.team_id)
|
||||
ON CONFLICT (team_id) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_payg_create_team_extensions ON stirling_pdf.teams;
|
||||
CREATE TRIGGER trg_payg_create_team_extensions
|
||||
AFTER INSERT ON stirling_pdf.teams
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION stirling_pdf.payg_create_team_extensions_trigger();
|
||||
|
||||
COMMENT ON TRIGGER trg_payg_create_team_extensions ON stirling_pdf.teams IS
|
||||
'Ensures every team has a payg_team_extensions sidecar row from creation. New customers '
|
||||
'are PAYG-default from minute one — they consume free-tier units until they add a card.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. Backfill: any existing team without a sidecar row gets one now
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO stirling_pdf.payg_team_extensions(team_id)
|
||||
SELECT t.team_id
|
||||
FROM stirling_pdf.teams t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM stirling_pdf.payg_team_extensions x WHERE x.team_id = t.team_id
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 6. RLS policy
|
||||
--
|
||||
-- Service-role bypasses RLS (backend reads + day-1 migration script writes via the service-role
|
||||
-- key). For user-initiated writes via the frontend Add-Card flow, only team LEADERs can link a
|
||||
-- subscription. SELECT remains permissive — anyone in the team can see the row.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_team_extensions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Read: any team member can see their team's payg row.
|
||||
DROP POLICY IF EXISTS payg_team_ext_select ON stirling_pdf.payg_team_extensions;
|
||||
CREATE POLICY payg_team_ext_select
|
||||
ON stirling_pdf.payg_team_extensions
|
||||
FOR SELECT
|
||||
USING (
|
||||
team_id IN (
|
||||
SELECT tm.team_id
|
||||
FROM stirling_pdf.team_memberships tm
|
||||
JOIN stirling_pdf.users u ON u.user_id = tm.user_id
|
||||
WHERE u.supabase_auth_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Update: only LEADERs of the team can update (i.e. link / unlink a subscription).
|
||||
DROP POLICY IF EXISTS payg_team_ext_leader_update ON stirling_pdf.payg_team_extensions;
|
||||
CREATE POLICY payg_team_ext_leader_update
|
||||
ON stirling_pdf.payg_team_extensions
|
||||
FOR UPDATE
|
||||
USING (
|
||||
team_id IN (
|
||||
SELECT tm.team_id
|
||||
FROM stirling_pdf.team_memberships tm
|
||||
JOIN stirling_pdf.users u ON u.user_id = tm.user_id
|
||||
WHERE u.supabase_auth_id = auth.uid()
|
||||
AND tm.role = 'LEADER'
|
||||
)
|
||||
);
|
||||
@@ -1,76 +0,0 @@
|
||||
-- PAYG audit-log tables. Two append-only logs:
|
||||
--
|
||||
-- * payg_meter_event_log — written by the backend's PaygMeterReportingService
|
||||
-- on every Stripe meter event POST attempt. Gives us a
|
||||
-- record independent of Stripe's own logs so we can
|
||||
-- replay-after-24h-window (Stripe's idempotency window)
|
||||
-- and run nightly reconciliation against Stripe's
|
||||
-- meter-event list.
|
||||
--
|
||||
-- * payg_subscription_change_log — written by V14's two RPC functions on every
|
||||
-- subscription link / unlink. Independent of Stripe's
|
||||
-- webhook log; lets us diagnose "why is this team in
|
||||
-- free-tier-block when their Stripe sub is active?"
|
||||
-- without leaving our DB.
|
||||
--
|
||||
-- Both are pure additive; nothing reads them yet. PR-SB-5 (nightly reconcile) wires the
|
||||
-- meter-event log; the subscription change log is queried only from admin tooling.
|
||||
--
|
||||
-- Design references:
|
||||
-- * payg-stripe-supabase-plan.html §3.10 — twin migrations
|
||||
-- * payg-stripe-supabase-plan.html §8 H5 — 24h idempotency window mitigation
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. payg_meter_event_log — backend-side audit of every Stripe meter event we tried to post.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.payg_meter_event_log (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
job_id UUID,
|
||||
idempotency_key VARCHAR(128) NOT NULL UNIQUE,
|
||||
units INTEGER NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
posted_to_stripe_at TIMESTAMP,
|
||||
-- NULL while pending; set when the meter-payg-units edge fn returns success. NULL after
|
||||
-- 24h means the event never made it to Stripe — nightly reconcile retries with a fresh
|
||||
-- idempotency-key suffix (see §8 H5 mitigation).
|
||||
stripe_error_code VARCHAR(64),
|
||||
stripe_error_body TEXT,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_meter_event_team_time
|
||||
ON stirling_pdf.payg_meter_event_log (team_id, occurred_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_meter_event_unposted
|
||||
ON stirling_pdf.payg_meter_event_log (occurred_at)
|
||||
WHERE posted_to_stripe_at IS NULL;
|
||||
|
||||
COMMENT ON TABLE stirling_pdf.payg_meter_event_log IS
|
||||
'Backend audit of every Stripe meter event POST attempt. Independent of Stripe meter '
|
||||
'history. idempotency_key is the same one passed to Stripe; the UNIQUE constraint here '
|
||||
'gives us safe at-least-once semantics even on backend retry. Rows older than 24h with '
|
||||
'posted_to_stripe_at IS NULL are stuck and retried by the nightly reconcile job.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. payg_subscription_change_log — written by V14's RPC functions on every link / unlink.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.payg_subscription_change_log (
|
||||
change_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
-- LINKED — payg_link_subscription written subscription_id
|
||||
-- UNLINKED — payg_unlink_subscription cleared the subscription
|
||||
subscription_id VARCHAR(128),
|
||||
reason TEXT,
|
||||
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_sub_change_team_time
|
||||
ON stirling_pdf.payg_subscription_change_log (team_id, changed_at);
|
||||
|
||||
COMMENT ON TABLE stirling_pdf.payg_subscription_change_log IS
|
||||
'Append-only log of every subscription link / unlink. Written by V14 RPC functions; '
|
||||
'never updated. Diagnostic value when reconciling against Stripe webhook history.';
|
||||
@@ -1,58 +0,0 @@
|
||||
-- PAYG analytics axis: stamp every billable ledger entry / shadow row with the category that
|
||||
-- produced it (API | AI | AUTOMATION | BYPASSED). PAYG stays on a single flat-priced Stripe meter
|
||||
-- forever — this column is for in-app breakdowns and analytics, never for Stripe pricing.
|
||||
--
|
||||
-- All adds are nullable: pre-V16 rows have no category and stay NULL; the interceptor populates
|
||||
-- it for new rows going forward.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. wallet_ledger.billing_category
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL;
|
||||
COMMENT ON COLUMN wallet_ledger.billing_category IS
|
||||
'API | AI | AUTOMATION | BYPASSED. NULL = system entry or pre-V16 backfill.';
|
||||
|
||||
-- Partial index — only billable rows ever read this column, and NULLs would just bloat the tree.
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_category_period
|
||||
ON wallet_ledger (team_id, billing_category, occurred_at)
|
||||
WHERE billing_category IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. payg_shadow_charge.billing_category + job_source
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE payg_shadow_charge
|
||||
ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL,
|
||||
ADD COLUMN IF NOT EXISTS job_source VARCHAR(32) NULL;
|
||||
|
||||
-- Backfill job_source from processing_job (best-effort — rows whose job has already been pruned
|
||||
-- stay NULL, which is fine: the shadow row is self-describing post-V16 and only legacy ones lack
|
||||
-- the column.)
|
||||
UPDATE payg_shadow_charge sc
|
||||
SET job_source = pj.source
|
||||
FROM processing_job pj
|
||||
WHERE pj.job_id = sc.job_id
|
||||
AND sc.job_source IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. pricing_policy_stripe_price.stripe_product_id
|
||||
-- Operator populates this manually per row when seeding new policies. Nullable for backward
|
||||
-- compatibility with existing rows that don't carry a Product reference.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE pricing_policy_stripe_price
|
||||
ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(128) NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. wallet_category_summary view — pre-grouped per-team, per-month, per-category aggregate that
|
||||
-- the in-app breakdown widget reads. Recomputed live on every SELECT; cheap thanks to the
|
||||
-- partial index above.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW wallet_category_summary AS
|
||||
SELECT
|
||||
team_id,
|
||||
date_trunc('month', occurred_at) AS period_start,
|
||||
billing_category,
|
||||
SUM(CASE WHEN amount_units < 0 THEN -amount_units ELSE 0 END) AS units_debited,
|
||||
COUNT(*) FILTER (WHERE entry_type = 'DEBIT') AS debit_count
|
||||
FROM wallet_ledger
|
||||
WHERE billing_category IS NOT NULL
|
||||
GROUP BY team_id, date_trunc('month', occurred_at), billing_category;
|
||||
@@ -1,34 +0,0 @@
|
||||
-- Consolidate users.supabase_id into users.supabase_auth_id.
|
||||
--
|
||||
-- The `supabase_auth_id` column is the canonical link to Supabase Auth — it was
|
||||
-- created by the initial Supabase schema migration (Sep 2025) and is referenced
|
||||
-- by every RLS policy in the Supabase side of the world (V14's
|
||||
-- payg_team_ext_select / payg_team_ext_leader_update, the public.payg_*
|
||||
-- SECURITY DEFINER RPCs, etc.).
|
||||
--
|
||||
-- PR #6384 ("SaaS Consolidation") accidentally added a parallel `supabase_id`
|
||||
-- column via Flyway V2 — same purpose, different name. Java's User entity then
|
||||
-- mapped to this new column. The result was a split-brain:
|
||||
-- * Pre-#6384 users had supabase_auth_id populated, supabase_id NULL.
|
||||
-- * Post-#6384 users had supabase_id populated, supabase_auth_id NULL.
|
||||
-- * RLS policies + RPCs always check supabase_auth_id, so post-#6384 users
|
||||
-- failed every membership check.
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Backfills supabase_auth_id from supabase_id where the former is NULL.
|
||||
-- 2. Drops the supabase_id column and its unique index.
|
||||
--
|
||||
-- The Java User entity has been switched to @Column(name = "supabase_auth_id")
|
||||
-- in the same change-set; this migration assumes the new code is already
|
||||
-- deployed (or will be deployed together with this migration).
|
||||
|
||||
-- 1. Backfill the canonical column from the duplicate, where needed.
|
||||
UPDATE users
|
||||
SET supabase_auth_id = supabase_id
|
||||
WHERE supabase_auth_id IS NULL
|
||||
AND supabase_id IS NOT NULL;
|
||||
|
||||
-- 2. Drop the duplicate column. IF EXISTS guards against environments where
|
||||
-- the column was already removed manually.
|
||||
DROP INDEX IF EXISTS uk_users_supabase_id;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS supabase_id;
|
||||
@@ -1,93 +0,0 @@
|
||||
-- PAYG free allowance: monthly per-cycle allowance → one-time LIFETIME grant.
|
||||
--
|
||||
-- Product decision (2026-06-11): every team gets a one-time free document grant. It does NOT
|
||||
-- replenish monthly and is NOT lost when the team subscribes — they keep whatever is unused.
|
||||
--
|
||||
-- Mechanics: the grant is tracked as a running counter on the team sidecar
|
||||
-- (payg_team_extensions.free_units_remaining), seeded once from the team's effective pricing
|
||||
-- policy and maintained by the charge pipeline (deducted when a billable DEBIT is written,
|
||||
-- restored on a first-step refund). Because the counter is authoritative, the wallet_ledger is
|
||||
-- no longer the source of truth for the grant and its old rows can be pruned after a retention
|
||||
-- window (separate future job).
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. Rename the policy column — it is no longer "per cycle", it's the one-time grant size.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.pricing_policy
|
||||
RENAME COLUMN free_tier_units_per_cycle TO free_tier_units;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units IS
|
||||
'One-time lifetime free document grant handed to a team on creation (copied into '
|
||||
'payg_team_extensions.free_units_remaining). NOT per-cycle: it never replenishes and '
|
||||
'survives subscribing. 0 = no free grant (block / meter from the first document).';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. The running counter on the team sidecar.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_team_extensions
|
||||
ADD COLUMN IF NOT EXISTS free_units_remaining BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.payg_team_extensions.free_units_remaining IS
|
||||
'Remaining one-time free documents for this team. Seeded from the effective pricing '
|
||||
'policy''s free_tier_units at row creation; decremented by min(jobUnits, remaining) when a '
|
||||
'billable charge is written; restored on a first-step refund. Lifetime — never resets. '
|
||||
'Authoritative source for the free grant (independent of wallet_ledger retention).';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. Per-job free/paid split on the shadow row — makes metering + refunds exact and removes
|
||||
-- any need to SUM the ledger over a team's lifetime.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_shadow_charge
|
||||
ADD COLUMN IF NOT EXISTS free_units_consumed INT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.payg_shadow_charge.free_units_consumed IS
|
||||
'How many of this job''s payg_units came out of the team''s free grant at charge time. '
|
||||
'Paid (metered) units = payg_units - free_units_consumed. A refund restores this many '
|
||||
'units to payg_team_extensions.free_units_remaining.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. Seed the counter at team creation. Replace the V14 trigger function so new teams get the
|
||||
-- default policy's grant from minute one. (The trigger itself still points at this function.)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO stirling_pdf.payg_team_extensions(team_id, free_units_remaining)
|
||||
VALUES (
|
||||
NEW.team_id,
|
||||
COALESCE(
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.is_default = TRUE LIMIT 1),
|
||||
0)
|
||||
)
|
||||
ON CONFLICT (team_id) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. Backfill existing teams. remaining = max(0, grant - lifetime_consumed). lifetime_consumed
|
||||
-- is -SUM(amount_units) over the team's DEBIT+REFUND ledger entries (debits negative, refunds
|
||||
-- positive), so grant + SUM(amount_units) collapses to grant - consumed. One-time read of the
|
||||
-- ledger; after this the counter stands alone. Grant = team override policy, else the default.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
UPDATE stirling_pdf.payg_team_extensions ext
|
||||
SET free_units_remaining = GREATEST(
|
||||
0,
|
||||
COALESCE(
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.policy_id = ext.pricing_policy_id),
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.is_default = TRUE LIMIT 1),
|
||||
0)
|
||||
+ COALESCE(
|
||||
(SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl
|
||||
WHERE wl.team_id = ext.team_id
|
||||
AND wl.entry_type IN ('DEBIT', 'REFUND')),
|
||||
0));
|
||||
@@ -1,42 +0,0 @@
|
||||
-- PAYG launch free grant: give the default pricing policy a real one-time grant.
|
||||
--
|
||||
-- V14 added pricing_policy.free_tier_units with DEFAULT 0, and the default policy seeded in V12
|
||||
-- predates the column — so on a fresh deploy every team's free_units_remaining seeds to 0 and
|
||||
-- V19's "every team gets a one-time free grant" intent ships dead (teams are gated / metered from
|
||||
-- the very first billable document). This migration sets the launch grant on the default policy
|
||||
-- and re-seeds existing teams that V19 left at 0 (V19 ran its backfill while the grant was still
|
||||
-- 0, so every then-existing team computed to 0).
|
||||
--
|
||||
-- The launch value lives on the default policy row; tune it there (or via a future admin surface).
|
||||
-- Both updates are guarded so a deliberately-tuned value — e.g. a smaller test grant — is never
|
||||
-- clobbered.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. Launch grant on the default policy, only where it's still the accidental 0.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
UPDATE stirling_pdf.pricing_policy
|
||||
SET free_tier_units = 500
|
||||
WHERE is_default = TRUE
|
||||
AND free_tier_units = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. Re-seed existing teams V19 left at 0. Same recompute as V19's backfill — remaining =
|
||||
-- max(0, grant + net signed DEBIT/REFUND) — now that the grant is non-zero. Guarded to
|
||||
-- free_units_remaining = 0: a team with a deliberately-set positive balance is left alone, and
|
||||
-- a team that genuinely exhausted a real grant also recomputes to 0, so the guard is safe.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
UPDATE stirling_pdf.payg_team_extensions ext
|
||||
SET free_units_remaining = GREATEST(
|
||||
0,
|
||||
COALESCE(
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.policy_id = ext.pricing_policy_id),
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.is_default = TRUE LIMIT 1),
|
||||
0)
|
||||
+ COALESCE(
|
||||
(SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl
|
||||
WHERE wl.team_id = ext.team_id
|
||||
AND wl.entry_type IN ('DEBIT', 'REFUND')),
|
||||
0))
|
||||
WHERE ext.free_units_remaining = 0;
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
-- Drop the unused wallet_category_summary view.
|
||||
--
|
||||
-- V16 created this view to back the wallet's per-category spend breakdown via
|
||||
-- WalletCategorySummaryDao. That DAO was never wired up — the breakdown is built from the JPA
|
||||
-- repository (WalletLedgerRepository.sumPeriodAmountByCategory) instead — so both the DAO and this
|
||||
-- view have zero readers. The DAO is deleted in the same change; this drops the dead view.
|
||||
--
|
||||
-- Done as a new migration (not by editing V16) so Flyway's checksum validation doesn't fail on
|
||||
-- databases that already applied V16. IF EXISTS keeps it safe on DBs where V16 hasn't run.
|
||||
|
||||
DROP VIEW IF EXISTS stirling_pdf.wallet_category_summary;
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Policy engine schema: persisted policies and the reusable input
|
||||
-- connections ("sources") they reference by id. The whole policy/source lives as JSON in the
|
||||
-- *_json column (authoritative on read); the scalar columns are denormalized copies for querying,
|
||||
-- notably team_id so a caller's team can be loaded without scanning every team's rows. owner and
|
||||
-- team_id are plain values, not foreign keys, to stay decoupled from the security entities (so this
|
||||
-- subsystem can be enabled or disabled without touching them). Hibernate ddl-auto would also create
|
||||
-- these, but this keeps the schema explicit for the Flyway-managed deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policies (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255),
|
||||
owner VARCHAR(255),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
trigger_type VARCHAR(255),
|
||||
team_id BIGINT,
|
||||
policy_json TEXT
|
||||
);
|
||||
|
||||
-- For deployments where Hibernate already created policies before this migration (pre-team_id).
|
||||
ALTER TABLE policies ADD COLUMN IF NOT EXISTS team_id BIGINT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_team ON policies (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_trigger ON policies (trigger_type, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_sources (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255),
|
||||
type VARCHAR(255),
|
||||
owner VARCHAR(255),
|
||||
team_id BIGINT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
source_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policy_sources_team ON policy_sources (team_id);
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Per-source document throughput, in two tables:
|
||||
--
|
||||
-- policy_source_doc_counts one row per source per hour bucket (hours-since-epoch), holding how
|
||||
-- many documents that source fed into runs in that hour. Feeds the
|
||||
-- rolling last-24h / last-30d windows and the 30-day daily series, and
|
||||
-- is pruned to that window so it stays bounded.
|
||||
-- policy_source_doc_totals a denormalized lifetime total per source, incremented alongside the
|
||||
-- hourly bucket, so the overview reads the all-time figure in one row
|
||||
-- instead of scanning a source's whole bucket history - and so the
|
||||
-- hourly buckets can be pruned without losing it.
|
||||
--
|
||||
-- Hibernate ddl-auto would also create these, but the migration keeps the schema explicit for the
|
||||
-- Flyway-managed deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_source_doc_counts (
|
||||
source_id VARCHAR(255) NOT NULL,
|
||||
bucket_hour BIGINT NOT NULL,
|
||||
doc_count BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (source_id, bucket_hour)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_source_doc_totals (
|
||||
source_id VARCHAR(255) NOT NULL,
|
||||
doc_total BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (source_id)
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
-- Account-link instances. One row per self-hosted instance that has linked a SaaS account.
|
||||
--
|
||||
-- Part of the combined-billing "Mode A" (connected self-hosted) flow:
|
||||
-- 1. An admin signs into their SaaS account in the Stirling Portal via the Supabase JS SDK
|
||||
-- (a short-lived Supabase JWT, refreshed client-side — it never reaches the server long-term).
|
||||
-- 2. That JWT is used ONCE to call POST /api/v1/account-link/register, which mints a
|
||||
-- device_id + device_secret bound to the admin's team. The secret is returned once and
|
||||
-- stored only on the instance; we keep a SHA-256 hash here (the secret is high-entropy,
|
||||
-- so an unsalted hash is sufficient — same posture as API keys).
|
||||
-- 3. The instance authenticates all unattended metering / entitlement calls with that device
|
||||
-- credential. No long-lived user JWT lives on the server side.
|
||||
--
|
||||
-- Twin of supabase/migrations/20260619000000_account_link_instances.sql (Stirling-PDF-SaaS).
|
||||
-- Inert until release: the AccountLinkController + device-credential filter are gated behind
|
||||
-- stirling.billing.account-link.enabled (default off). The table itself is harmless additive.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.linked_instance (
|
||||
instance_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
created_by_user_id BIGINT,
|
||||
-- admin who registered the instance; informational only (no FK so a user delete never
|
||||
-- cascades a working instance offline).
|
||||
device_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
-- public, non-secret identifier the instance presents on every request.
|
||||
device_secret_hash VARCHAR(64) NOT NULL,
|
||||
-- SHA-256 hex of the device secret; the secret itself is never stored.
|
||||
name VARCHAR(255),
|
||||
-- operator-set display label (hostname etc.) for the "Linked instances" list.
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TIMESTAMP,
|
||||
-- stamped when the device credential last authenticated; powers staleness display.
|
||||
revoked_at TIMESTAMP
|
||||
-- NULL = active. Set on unlink/revoke; a revoked credential fails authentication.
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_linked_instance_team
|
||||
ON stirling_pdf.linked_instance (team_id);
|
||||
|
||||
COMMENT ON TABLE stirling_pdf.linked_instance IS
|
||||
'One row per self-hosted instance linked to a SaaS account (combined-billing Mode A). '
|
||||
'device_id is the public identifier; device_secret_hash is the SHA-256 of the bearer '
|
||||
'secret (returned once at registration, stored only on the instance). The instance '
|
||||
'authenticates unattended metering / entitlement calls with this credential; revoked_at '
|
||||
'IS NULL means active.';
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Twin of supabase/migrations/<ts>_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table
|
||||
-- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS
|
||||
-- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS,
|
||||
-- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry).
|
||||
--
|
||||
-- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted
|
||||
-- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on
|
||||
-- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and
|
||||
-- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path
|
||||
-- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow.
|
||||
--
|
||||
-- Inert until release: written only by the InstanceController /sync endpoint, gated behind
|
||||
-- stirling.billing.account-link.enabled (default off). Additive, idempotent table.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
period_start TIMESTAMP NOT NULL,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
-- Highest cumulative unit total seen for this (team, period, category); the next sync bills
|
||||
-- (reported cumulative - this).
|
||||
last_cumulative_units BIGINT NOT NULL DEFAULT 0,
|
||||
-- Highest sync sequence applied; a sync at or below this is a replay and is ignored.
|
||||
last_sync_seq BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team
|
||||
ON stirling_pdf.payg_instance_usage (team_id);
|
||||
|
||||
COMMENT ON TABLE stirling_pdf.payg_instance_usage IS
|
||||
'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted '
|
||||
'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the '
|
||||
'standard charge path; last_sync_seq dedups replays.';
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Resource access grants: which user/team may use a gated resource (portal, integration config).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
resource_grant_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
resource_type VARCHAR(64) NOT NULL,
|
||||
resource_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
principal_type VARCHAR(32) NOT NULL,
|
||||
principal_id BIGINT NOT NULL,
|
||||
permission VARCHAR(32) NOT NULL,
|
||||
granted_by_user_id BIGINT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_resource_grant
|
||||
ON resource_grants (resource_type, resource_id, principal_type, principal_id, permission);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_lookup
|
||||
ON resource_grants (resource_type, resource_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_principal
|
||||
ON resource_grants (principal_type, principal_id);
|
||||
@@ -1,25 +0,0 @@
|
||||
-- S3/MCP/API integration configs; config_encrypted holds an AES-GCM encrypted JSON blob.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_configs (
|
||||
integration_config_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
integration_type VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
scope VARCHAR(32) NOT NULL,
|
||||
owner_user_id BIGINT,
|
||||
owner_team_id BIGINT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
default_access VARCHAR(32) NOT NULL DEFAULT 'EXPLICIT_ONLY',
|
||||
config_encrypted TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_configs_owner
|
||||
ON integration_configs (owner_user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_configs_type
|
||||
ON integration_configs (integration_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_configs_scope
|
||||
ON integration_configs (scope);
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
-- Twin of supabase/migrations/<ts>_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS).
|
||||
-- Keep byte-identical to the Supabase twin.
|
||||
--
|
||||
-- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode
|
||||
-- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which
|
||||
-- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after
|
||||
-- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync.
|
||||
--
|
||||
-- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase
|
||||
-- migration sets against the same schema) and additive (the new set is a superset of the JobSource
|
||||
-- enum; the app only ever writes enum values, so no existing row can violate it).
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_shadow_charge
|
||||
DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check;
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_shadow_charge
|
||||
ADD CONSTRAINT payg_shadow_charge_job_source_check
|
||||
CHECK (job_source IS NULL
|
||||
OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE'));
|
||||
@@ -1,72 +0,0 @@
|
||||
-- Enterprise procurement: the tables that track a linked team's journey from trial to live.
|
||||
--
|
||||
-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the
|
||||
-- quotes built against it (the itemised, priced offers), and an append-only activity log for the
|
||||
-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions
|
||||
-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence
|
||||
-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService).
|
||||
--
|
||||
-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal (
|
||||
deal_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
|
||||
-- one active deal per team; the journey lives on this row.
|
||||
stage VARCHAR(32) NOT NULL DEFAULT 'trial',
|
||||
-- trial | quote | security (agreement) | procurement (payment) | active (live)
|
||||
trial_started_at TIMESTAMP,
|
||||
trial_ends_at TIMESTAMP,
|
||||
trial_extensions_used INT NOT NULL DEFAULT 0,
|
||||
license_ref VARCHAR(128),
|
||||
-- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands.
|
||||
subscription_id VARCHAR(255),
|
||||
-- Stripe subscription id, mirrored into billing_subscriptions once commercial.
|
||||
accepted_quote_id BIGINT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote (
|
||||
quote_id BIGSERIAL PRIMARY KEY,
|
||||
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
|
||||
quote_number VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'draft',
|
||||
-- draft | sent | accepted | expired
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'USD',
|
||||
volume BIGINT NOT NULL,
|
||||
seats INT,
|
||||
deployment VARCHAR(24),
|
||||
term_years INT NOT NULL,
|
||||
service_level VARCHAR(24) NOT NULL,
|
||||
indemnification BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
training BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
qbr BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
annual_net_minor BIGINT NOT NULL,
|
||||
-- recurring annual fee after the multi-year discount, in minor units (cents).
|
||||
tcv_minor BIGINT NOT NULL,
|
||||
-- total contract value across the term incl. one-time fees, minor units.
|
||||
line_items TEXT,
|
||||
-- JSON snapshot of the itemised lines the order form renders.
|
||||
stripe_price_id VARCHAR(128),
|
||||
checkout_session_id VARCHAR(255),
|
||||
checkout_url TEXT,
|
||||
valid_until DATE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity (
|
||||
activity_id BIGSERIAL PRIMARY KEY,
|
||||
deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
|
||||
actor_user_id BIGINT,
|
||||
-- the internal/portal user who took the action; informational (no FK).
|
||||
action VARCHAR(48) NOT NULL,
|
||||
-- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ...
|
||||
detail TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id);
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF +
|
||||
-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The
|
||||
-- Stripe operations live in Supabase edge functions; these columns hold the references they write
|
||||
-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on
|
||||
-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS business_name VARCHAR(255);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- SaaS-only column additions on top of the OSS users schema. Idempotent.
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS supabase_id UUID;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_email ON users (email);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_supabase_id ON users (supabase_id);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Classification labels: the flat multi-label vocabulary the document
|
||||
-- classifier runs against. One admin-editable row per team. The whole label set lives as JSON in
|
||||
-- labels_json (authoritative on read). team_id is a natural key and a plain value (not a foreign
|
||||
-- key) to stay decoupled from the security entities, so classification can be enabled or disabled
|
||||
-- without touching them; the sentinel 0 holds the unteamed (login-disabled) team set. Hibernate
|
||||
-- ddl-auto would also create this, but this keeps the schema explicit for Flyway-managed deploys.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classification_labels (
|
||||
team_id BIGINT PRIMARY KEY,
|
||||
labels_json TEXT,
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR(255)
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Offline / air-gapped licence add-on flag on the quote (a paid add-on; priced like QBR). Written
|
||||
-- and read by the Java backend via JPA. Twin of Supabase migration
|
||||
-- 20260710000000_procurement_offline_license.sql.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS offline_license BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Team-wide policy run order (see PolicyEntity.sortOrder). The order policies run in is stored
|
||||
-- server-side and shared by the whole team (not per-user in the browser), and is admin-editable.
|
||||
-- Nullable so existing rows keep working; reads treat a null as 0 (coalesce), and the store
|
||||
-- appends a new policy at max(order)+1 so setting one up adds it to the end of the queue.
|
||||
ALTER TABLE policies ADD COLUMN sort_order INTEGER;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Policy posture (runs per PDF) on the quote: the D71 meter is denominated in runs, so a quote
|
||||
-- must remember the posture it was priced at (Essentials 2, Governed 4, Regulated 7). Defaults to
|
||||
-- Governed (4). Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS intensity INTEGER NOT NULL DEFAULT 4;
|
||||
@@ -1,36 +0,0 @@
|
||||
-- Per-policy processed-file ledger:
|
||||
--
|
||||
-- policy_processed_files one row per (policy, file identity) recording the version a policy
|
||||
-- last settled that file at, so folder sources track files in place
|
||||
-- instead of moving them into a work directory. signature is a cheap
|
||||
-- version gate (folder: size:mtime); content_hash an optional strong
|
||||
-- token consulted only when the gate moves. Rows are claimed into
|
||||
-- PROCESSING, settled to DONE/ERROR, flipped to INTERRUPTED at boot if
|
||||
-- a run died with the JVM, and pruned once the file is gone from all of
|
||||
-- the policy's sources, so the table stays near the set of files
|
||||
-- currently present.
|
||||
--
|
||||
-- Hibernate ddl-auto would also create this, but the migration keeps the schema explicit for the
|
||||
-- Flyway-managed deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_processed_files (
|
||||
policy_id VARCHAR(255) NOT NULL,
|
||||
identity_hash VARCHAR(64) NOT NULL,
|
||||
identity VARCHAR(4096),
|
||||
signature VARCHAR(255) NOT NULL,
|
||||
content_hash VARCHAR(64),
|
||||
status VARCHAR(16) NOT NULL,
|
||||
attempts SMALLINT NOT NULL DEFAULT 1,
|
||||
last_seen BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (policy_id, identity_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processed_files_policy_seen
|
||||
ON policy_processed_files (policy_id, last_seen);
|
||||
|
||||
-- The cross-policy deletion-consensus check (existsByIdentityHashAndStatusNot) filters identity_hash
|
||||
-- alone, so it cannot use the (policy_id, identity_hash) primary key; it runs once per successfully
|
||||
-- consumed file, so index it to avoid a full scan on the hot path.
|
||||
CREATE INDEX IF NOT EXISTS idx_processed_files_identity
|
||||
ON policy_processed_files (identity_hash);
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Direct PDF link for a procurement quote's first invoice (Stripe invoice_pdf), stored at accept
|
||||
-- alongside stripe_invoice_url so the portal's "Download invoice" button survives a reload instead
|
||||
-- of relying on the transient accept response. Written by the accept edge function via the
|
||||
-- procurement_set_quote_accepted RPC; read by the Java backend via JPA. A Supabase twin mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS stripe_invoice_pdf TEXT;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Deployment target + seat count captured at the trial-start step (the setup dialog the demo shows
|
||||
-- before a trial begins), stored on the deal so the quote builder seeds from the buyer's real
|
||||
-- environment instead of a hardcoded default. deployment: cloud | selfhost | airgap. seats: 0 =
|
||||
-- unspecified. Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_deal
|
||||
ADD COLUMN IF NOT EXISTS deployment VARCHAR(16) NOT NULL DEFAULT 'cloud';
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_deal
|
||||
ADD COLUMN IF NOT EXISTS seats INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Persist the first post-term renewal fee (annual net + one CPI step) computed at quote time, so the
|
||||
-- figure shown to the buyer is locked to what they were quoted rather than recomputed from the
|
||||
-- current rate card on every read. Minor units. Written and read by the Java backend via JPA; a
|
||||
-- Supabase twin migration mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS renewal_annual_minor BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- File-size tier multiplier on the quote (D93): larger, image-heavy PDFs cost more, so the buyer
|
||||
-- picks a size tier (Compact 1.0 / Standard 1.4 / Heavy 2.4) that scales the per-run rate. Persisted
|
||||
-- so the quote re-prices and re-seeds the builder consistently. Defaults to 1.0 (no uplift) for rows
|
||||
-- that predate the column. Written and read by the Java backend via JPA. A Supabase twin mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS size_mult DOUBLE PRECISION NOT NULL DEFAULT 1.0;
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
-- Buyer / AP details captured on the quote's "Your details" step: the signatory contact and a
|
||||
-- billing address, plus a PO number and tax id for the invoice. All optional (never gate quote
|
||||
-- generation). Persisted so the quote re-seeds the builder on a re-edit and so the issue edge
|
||||
-- function can put them on the Stripe customer (name + bill-to address) and invoice (PO / tax id
|
||||
-- as custom fields). Country and currency are intentionally out of scope for now. Written and read
|
||||
-- by the Java backend via JPA. A Supabase twin mirrors these columns.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS address_line2 VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS city VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS region VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS postal_code VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS po_number VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS tax_id VARCHAR(64);
|
||||
@@ -1,39 +0,0 @@
|
||||
-- PAYG size-scaled billing: run-scoped grouping + per-input-file counting.
|
||||
--
|
||||
-- Two independent axes now live on a charge:
|
||||
-- * doc_units — billing quantity, scales with file size (existing column)
|
||||
-- * doc_count — number of INPUT files (the "unique PDFs" dimension); a split (1→many) stays 1,
|
||||
-- a merge (N→1) is N. Fixed at open; joined steps never change it.
|
||||
-- Plus run_id, the automation-run correlation id used to group a run's tool sub-steps into one
|
||||
-- charge (replacing the old content+time-window grouping) and to keep two separate runs distinct.
|
||||
--
|
||||
-- Everything is additive; no existing rows are modified, no columns dropped.
|
||||
|
||||
-- ── processing_job ───────────────────────────────────────────────────────────
|
||||
ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS run_id VARCHAR(64);
|
||||
ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
COMMENT ON COLUMN processing_job.run_id IS
|
||||
'Automation-run correlation id (X-Stirling-Run-Id); NULL for a standalone tool call. Lineage '
|
||||
'joins are scoped to one run_id, so separate runs never merge even on identical bytes.';
|
||||
COMMENT ON COLUMN processing_job.doc_count IS
|
||||
'Number of input files this charge represents (the count dimension, distinct from size-scaled '
|
||||
'doc_units). Split=1, merge=N. Fixed at open.';
|
||||
|
||||
-- ── wallet_ledger ────────────────────────────────────────────────────────────
|
||||
-- Denormalise the count dimension + input fingerprint onto the DEBIT row so usage analytics
|
||||
-- (unique PDFs, per-category counts, size-multiplier average) query one table and survive
|
||||
-- processing_job pruning.
|
||||
ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS document_fingerprint VARCHAR(64);
|
||||
|
||||
COMMENT ON COLUMN wallet_ledger.doc_count IS
|
||||
'Input-file count for this entry (mirrors processing_job.doc_count); summed for "PDFs processed".';
|
||||
COMMENT ON COLUMN wallet_ledger.document_fingerprint IS
|
||||
'SHA-256 of the entry''s input file set; COUNT(DISTINCT ...) gives unique PDFs. NULL for '
|
||||
'aggregate/system entries (e.g. linked-instance sync).';
|
||||
|
||||
-- Distinct-PDF + size-multiplier queries scan by team + period.
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_period_fp
|
||||
ON wallet_ledger (team_id, occurred_at, document_fingerprint)
|
||||
WHERE document_fingerprint IS NOT NULL;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine
|
||||
-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created
|
||||
-- in V30) is no longer read or written. Drop it.
|
||||
--
|
||||
-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after
|
||||
-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created
|
||||
-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already
|
||||
-- absent, keeping the migration safe to apply regardless of prior state.
|
||||
|
||||
DROP TABLE IF EXISTS classification_labels;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- Stripe billing subscription mirror, populated by Supabase webhooks.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billing_subscriptions (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
user_id UUID NOT NULL,
|
||||
team_id BIGINT,
|
||||
status VARCHAR(64) NOT NULL,
|
||||
price_id VARCHAR(255),
|
||||
current_period_end TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_user_id ON billing_subscriptions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_team_id ON billing_subscriptions (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_status ON billing_subscriptions (status);
|
||||
@@ -1,39 +0,0 @@
|
||||
-- Per-user and per-team credit pools.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_credits (
|
||||
credit_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
cycle_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
cycle_credits_allocated INTEGER NOT NULL DEFAULT 0,
|
||||
bought_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
total_bought_credits INTEGER NOT NULL DEFAULT 0,
|
||||
last_cycle_reset_at TIMESTAMP,
|
||||
last_api_usage TIMESTAMP,
|
||||
total_api_calls_made BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uk_user_credits_user_id UNIQUE (user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credits_user_id ON user_credits (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credits_last_reset ON user_credits (last_cycle_reset_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credits_last_usage ON user_credits (last_api_usage);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_credits (
|
||||
credit_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES teams(id) ON DELETE CASCADE,
|
||||
cycle_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
cycle_credits_allocated INTEGER NOT NULL DEFAULT 0,
|
||||
bought_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
total_bought_credits INTEGER NOT NULL DEFAULT 0,
|
||||
last_cycle_reset_at TIMESTAMP,
|
||||
last_api_usage TIMESTAMP,
|
||||
total_api_calls_made BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_credits_team_id ON team_credits (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_credits_last_reset ON team_credits (last_cycle_reset_at);
|
||||
@@ -1,40 +0,0 @@
|
||||
-- Team memberships and email-based team invitations.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_memberships (
|
||||
membership_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'MEMBER',
|
||||
invited_by_user_id BIGINT REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
invited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_team_memberships_team_user UNIQUE (team_id, user_id),
|
||||
CONSTRAINT chk_team_memberships_role CHECK (role IN ('LEADER', 'MEMBER'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_memberships_team ON team_memberships (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_memberships_user ON team_memberships (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_memberships_team_role ON team_memberships (team_id, role);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_invitations (
|
||||
invitation_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
inviter_user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
invitee_email VARCHAR(255) NOT NULL,
|
||||
invitee_user_id BIGINT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
|
||||
invitation_token VARCHAR(255) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_team_invitations_status CHECK (
|
||||
status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'EXPIRED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_team ON team_invitations (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_email ON team_invitations (invitee_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_token ON team_invitations (invitation_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_status ON team_invitations (status);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Per-user processing-error tracker.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_error_tracker (
|
||||
error_tracker_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
endpoint VARCHAR(255),
|
||||
processing_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_processing_error TIMESTAMP,
|
||||
reset_after TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_error_tracker_user_id ON user_error_tracker (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_error_tracker_endpoint ON user_error_tracker (endpoint);
|
||||
@@ -1,28 +0,0 @@
|
||||
-- AI document-creation sessions for chat / outline-to-PDF flows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_create_sessions (
|
||||
session_id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
doc_type VARCHAR(255),
|
||||
template_id VARCHAR(255),
|
||||
template_tex VARCHAR(255),
|
||||
preview_tex VARCHAR(255),
|
||||
prompt_initial TEXT,
|
||||
prompt_latest TEXT,
|
||||
outline_text TEXT,
|
||||
outline_filename VARCHAR(255),
|
||||
outline_approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
outline_constraints TEXT,
|
||||
draft_sections TEXT,
|
||||
polished_latex TEXT,
|
||||
pdf_url VARCHAR(2048),
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_id ON ai_create_sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_updated_at ON ai_create_sessions (updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_pdf
|
||||
ON ai_create_sessions (user_id, updated_at DESC)
|
||||
WHERE pdf_url IS NOT NULL;
|
||||
@@ -1,77 +0,0 @@
|
||||
-- Saas-only sidecar tables for user and team metadata.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saas_user_extensions (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
has_metered_billing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
api_key_first_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_user_extensions_metered_billing
|
||||
ON saas_user_extensions (has_metered_billing_enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saas_team_extensions (
|
||||
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
|
||||
team_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD',
|
||||
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
seat_count INTEGER NOT NULL DEFAULT 1,
|
||||
seats_used INTEGER NOT NULL DEFAULT 0,
|
||||
max_seats INTEGER NOT NULL DEFAULT 1,
|
||||
created_by_user_id BIGINT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_is_personal
|
||||
ON saas_team_extensions (is_personal);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_created_by_user_id
|
||||
ON saas_team_extensions (created_by_user_id);
|
||||
|
||||
-- Backfill from any pre-existing columns on users / teams, then drop them.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'has_metered_billing_enabled'
|
||||
) THEN
|
||||
INSERT INTO saas_user_extensions (user_id, has_metered_billing_enabled, api_key_first_used_at)
|
||||
SELECT user_id, COALESCE(has_metered_billing_enabled, FALSE), api_key_first_used_at
|
||||
FROM users
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'teams'
|
||||
AND column_name = 'team_type'
|
||||
) THEN
|
||||
INSERT INTO saas_team_extensions (
|
||||
team_id, team_type, is_personal, seat_count, seats_used, max_seats, created_by_user_id
|
||||
)
|
||||
SELECT id,
|
||||
COALESCE(team_type, 'STANDARD'),
|
||||
COALESCE(is_personal, FALSE),
|
||||
COALESCE(seat_count, 1),
|
||||
COALESCE(seats_used, 0),
|
||||
COALESCE(max_seats, 1),
|
||||
created_by_user_id
|
||||
FROM teams
|
||||
ON CONFLICT (team_id) DO NOTHING;
|
||||
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS team_type;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS is_personal;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS seat_count;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS seats_used;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS max_seats;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS created_by_user_id;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS has_metered_billing_enabled;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS api_key_first_used_at;
|
||||
END
|
||||
$$;
|
||||
@@ -1,8 +1,9 @@
|
||||
services:
|
||||
# ---------------------------------------------------------------------
|
||||
# Postgres holding the stirling_pdf schema. Flyway migrates V1-V13 on
|
||||
# backend startup against this DB. Exposed on host port 5433 so the
|
||||
# cucumber harness can connect via psycopg from features/steps/payg_*.
|
||||
# Postgres holding the stirling_pdf schema, built by Hibernate
|
||||
# ddl-auto=create-drop when the backend starts (see below). Exposed on
|
||||
# host port 5433 so the cucumber harness can connect via psycopg from
|
||||
# features/steps/payg_*.
|
||||
# ---------------------------------------------------------------------
|
||||
postgres-saas:
|
||||
image: postgres:17-alpine
|
||||
@@ -69,14 +70,11 @@ services:
|
||||
SAAS_DB_PASSWORD: "postgres"
|
||||
SAAS_DB_PROJECT_REF: "disabled"
|
||||
|
||||
# The saas Flyway migrations assume `users` and `teams` already exist
|
||||
# (V2 ALTERs `users`; V5 references `teams(id)`) because in production
|
||||
# those tables are provisioned by Supabase before Stirling starts. On
|
||||
# a clean test postgres they don't exist, so we disable Flyway and let
|
||||
# Hibernate's `ddl-auto=create-drop` build the full schema from the
|
||||
# entity graph. The default-pricing-policy row that V12 seeds in
|
||||
# production is re-seeded by testing/compose/payg/saas-seed.sql.
|
||||
SPRING_FLYWAY_ENABLED: "false"
|
||||
# On a clean test postgres the Supabase-provisioned `users`/`teams`
|
||||
# tables don't exist, so we let Hibernate's `ddl-auto=create-drop`
|
||||
# build the full schema from the entity graph. The default-pricing-
|
||||
# policy row (seeded in production by the Supabase migrations) is
|
||||
# re-seeded here by testing/compose/payg/saas-seed.sql.
|
||||
SPRING_JPA_HIBERNATE_DDL_AUTO: "create-drop"
|
||||
|
||||
# Disable Supabase JWT enforcement for the test profile. The
|
||||
|
||||
Reference in New Issue
Block a user