mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
PAYG: V14 + V15 — subscription_id, free-tier, RPCs, audit logs (#6532)
## Summary Two Flyway migrations + matching JPA entity updates. **Part 1 of 2** in the Stripe/Supabase wire-up (PR-SB-1 in `payg-stripe-supabase-plan.html`); the companion SaaS PR carries the twin Supabase migrations + new edge functions. ### V14 — payg_subscription_state.sql - `payg_team_extensions.payg_subscription_id` — the single switch that decides whether a team is billed. NULL = free-tier or block; NOT NULL = post Stripe meter events. - `pricing_policy.free_tier_units_per_cycle` — per-policy free allowance before a card is required. Default 0. - `payg_link_subscription(team_id, customer_id, sub_id)` RPC — idempotent. - `payg_unlink_subscription(team_id, reason)` RPC — called on `subscription.deleted`. - AFTER-INSERT trigger on `teams` so every new signup gets a `payg_team_extensions` sidecar row from creation. - Backfill for existing teams without a sidecar row. - RLS: SELECT permissive (any team member), UPDATE restricted to LEADER. Service-role bypasses (backend reads + day-1 migration writes). ### V15 — payg_audit_logs.sql - `payg_meter_event_log` — backend audit of every Stripe meter event POST attempt (idempotency-key UNIQUE; index on unposted rows for nightly reconcile). - `payg_subscription_change_log` — written by V14 RPCs on every link/unlink. ### Entity updates - `PaygTeamExtensions.paygSubscriptionId` — read-only field; RPC functions are the only writers. - `PricingPolicy.freeTierUnitsPerCycle` — read by upcoming `PaygTeamUsageService` (PR-SB-4). ### Behaviour change **None yet.** The columns + functions sit unused until PR-SB-4 wires `PaygMeterReportingService` and the free-tier gate into `JobChargeService`. This PR is pure schema + JPA wiring. ## Test plan - [x] `./gradlew :saas:test` — BUILD SUCCESSFUL - [x] Manual schema review: column types, FK directions, RLS scope - [ ] Apply against v3-Supabase via `supabase db push` (after companion SaaS PR merges) - [ ] Smoke-test the trigger: `INSERT INTO teams(...)` → assert `payg_team_extensions` row appears - [ ] Smoke-test RPCs: SQL-only test of `payg_link_subscription` + `payg_unlink_subscription` produces expected row + audit entries ## References - `notes/PAYG_DESIGN.md` (revision note 2026-06-03) - `payg-stripe-supabase-plan.html` §3.1 (RPC functions), §3.5 (RLS), §3.10 (audit-log tables)
This commit is contained in:
@@ -58,6 +58,19 @@ public class PaygTeamExtensions implements Serializable {
|
||||
@Column(name = "stripe_customer_id", unique = true, length = 128)
|
||||
private String stripeCustomerId;
|
||||
|
||||
/**
|
||||
* Stripe subscription id (sub_xxx) for this team's PAYG metered subscription. {@code null}
|
||||
* means the team has not added a card yet — engine writes shadow rows only and free-tier gating
|
||||
* applies. {@code non-null} means engine posts meter events to Stripe on every billable tool
|
||||
* call.
|
||||
*
|
||||
* <p>This is the single switch that determines whether a team is billed. Mutated only by the
|
||||
* {@code payg_link_subscription} / {@code payg_unlink_subscription} Postgres functions (V14) —
|
||||
* never directly via JPA writes. Treat as read-only from Java.
|
||||
*/
|
||||
@Column(name = "payg_subscription_id", unique = true, length = 128)
|
||||
private String paygSubscriptionId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -73,6 +73,20 @@ public class PricingPolicy implements Serializable {
|
||||
@Column(name = "file_unit_cap", nullable = false)
|
||||
private Integer fileUnitCap = 1000;
|
||||
|
||||
/**
|
||||
* Free-tier allowance — doc units a team on this policy can consume per cycle before they must
|
||||
* add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their
|
||||
* very first tool call until they pay. New-signup defaults will set this to a sensible positive
|
||||
* value; the special launch policy used by the day-1 legacy migration script can override with
|
||||
* a different size if product wants the original 10 customers to feel special.
|
||||
*
|
||||
* <p>Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code
|
||||
* payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass
|
||||
* this check entirely.
|
||||
*/
|
||||
@Column(name = "free_tier_units_per_cycle", nullable = false)
|
||||
private Long freeTierUnitsPerCycle = 0L;
|
||||
|
||||
/**
|
||||
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
|
||||
* JobSource}. Self-hosted teams typically get a higher limit via a per-team policy override.
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
-- 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'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
-- 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.';
|
||||
Reference in New Issue
Block a user