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:
ConnorYoh
2026-06-09 14:48:05 +00:00
committed by GitHub
parent ff96a80947
commit 98967bfa86
4 changed files with 292 additions and 0 deletions
@@ -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.';