diff --git a/app/saas/build.gradle b/app/saas/build.gradle index 38a16f7de8..495f583a74 100644 --- a/app/saas/build.gradle +++ b/app/saas/build.gradle @@ -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}" } diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index a6b594dd96..8275970929 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -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. diff --git a/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql b/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql deleted file mode 100644 index b7450984b2..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V11__saas_payg_model.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql b/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql deleted file mode 100644 index d587a80b2e..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V12__seed_default_payg_policy.sql +++ /dev/null @@ -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 - ); diff --git a/app/saas/src/main/resources/db/migration/saas/V13__payg_shadow_charge_status.sql b/app/saas/src/main/resources/db/migration/saas/V13__payg_shadow_charge_status.sql deleted file mode 100644 index 66da92a108..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V13__payg_shadow_charge_status.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V14__payg_subscription_state.sql b/app/saas/src/main/resources/db/migration/saas/V14__payg_subscription_state.sql deleted file mode 100644 index eca2665de7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V14__payg_subscription_state.sql +++ /dev/null @@ -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' - ) - ); diff --git a/app/saas/src/main/resources/db/migration/saas/V15__payg_audit_logs.sql b/app/saas/src/main/resources/db/migration/saas/V15__payg_audit_logs.sql deleted file mode 100644 index 1b7e61e9c1..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V15__payg_audit_logs.sql +++ /dev/null @@ -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.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql deleted file mode 100644 index f78458a1b6..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql b/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql deleted file mode 100644 index cc6b5d033e..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql deleted file mode 100644 index ed62b34e02..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql +++ /dev/null @@ -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)); diff --git a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql deleted file mode 100644 index d9dc756e31..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql b/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql deleted file mode 100644 index 857e0bfa65..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql b/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql deleted file mode 100644 index b07ad1cebf..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V22__policy_engine_tables.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql b/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql deleted file mode 100644 index bf50191893..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V23__policy_source_doc_counts.sql +++ /dev/null @@ -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) -); diff --git a/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql b/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql deleted file mode 100644 index 9c975f1ce7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V24__account_link_instances.sql +++ /dev/null @@ -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.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql deleted file mode 100644 index 7ab65b8b78..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Twin of supabase/migrations/_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.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql b/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql deleted file mode 100644 index f1eab58b15..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V25__resource_grants.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql b/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql deleted file mode 100644 index c78660248e..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V26__integration_configs.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql deleted file mode 100644 index f79097e325..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Twin of supabase/migrations/_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')); diff --git a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql deleted file mode 100644 index 0048f999c1..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql deleted file mode 100644 index 5d83ee35f8..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql deleted file mode 100644 index ba9c74dde8..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V2__saas_columns.sql b/app/saas/src/main/resources/db/migration/saas/V2__saas_columns.sql deleted file mode 100644 index 74f591c1df..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V2__saas_columns.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql b/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql deleted file mode 100644 index 6ad5ce7cc5..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V30__classification_labels.sql +++ /dev/null @@ -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) -); diff --git a/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql b/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql deleted file mode 100644 index e7ca0233a7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql b/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql deleted file mode 100644 index 2b2bdbd25b..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V31__policy_sort_order.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql b/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql deleted file mode 100644 index 4920d52d26..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql deleted file mode 100644 index 3a3e136437..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql deleted file mode 100644 index 946b8415c6..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql deleted file mode 100644 index 186e180cb2..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql deleted file mode 100644 index 75a4479c31..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql deleted file mode 100644 index 7c213cb3dc..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql deleted file mode 100644 index 3f07f201bb..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql b/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql deleted file mode 100644 index bc1a4e0f16..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V39__drop_classification_labels.sql b/app/saas/src/main/resources/db/migration/saas/V39__drop_classification_labels.sql deleted file mode 100644 index 9ef97f9f99..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V39__drop_classification_labels.sql +++ /dev/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; diff --git a/app/saas/src/main/resources/db/migration/saas/V3__saas_billing.sql b/app/saas/src/main/resources/db/migration/saas/V3__saas_billing.sql deleted file mode 100644 index 169f58cce5..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V3__saas_billing.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V4__saas_credits.sql b/app/saas/src/main/resources/db/migration/saas/V4__saas_credits.sql deleted file mode 100644 index 63384bdd5a..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V4__saas_credits.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V5__saas_teams.sql b/app/saas/src/main/resources/db/migration/saas/V5__saas_teams.sql deleted file mode 100644 index dccacfe2b7..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V5__saas_teams.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V6__saas_user_error_tracker.sql b/app/saas/src/main/resources/db/migration/saas/V6__saas_user_error_tracker.sql deleted file mode 100644 index 12f6a16b00..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V6__saas_user_error_tracker.sql +++ /dev/null @@ -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); diff --git a/app/saas/src/main/resources/db/migration/saas/V8__saas_ai_create_sessions.sql b/app/saas/src/main/resources/db/migration/saas/V8__saas_ai_create_sessions.sql deleted file mode 100644 index 1b8364793c..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V8__saas_ai_create_sessions.sql +++ /dev/null @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V9__saas_user_team_extensions.sql b/app/saas/src/main/resources/db/migration/saas/V9__saas_user_team_extensions.sql deleted file mode 100644 index df9b8000ca..0000000000 --- a/app/saas/src/main/resources/db/migration/saas/V9__saas_user_team_extensions.sql +++ /dev/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 -$$; diff --git a/testing/compose/docker-compose-saas.yml b/testing/compose/docker-compose-saas.yml index 880518b423..1965bb5a64 100644 --- a/testing/compose/docker-compose-saas.yml +++ b/testing/compose/docker-compose-saas.yml @@ -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