fix(ai-credits): whole subscription-lifecycle subsystem was non-functional
All checks were successful
build-and-release / build (cron) (push) Successful in 1m10s
build-and-release / build (catering-services) (push) Successful in 1m36s
build-and-release / build (companies) (push) Successful in 2m1s
build-and-release / build (customers) (push) Successful in 2m18s
build-and-release / build (employees) (push) Successful in 2m37s
build-and-release / build (developers) (push) Successful in 2m40s
build-and-release / build (gateway) (push) Successful in 1m19s
build-and-release / build (graphic-designers) (push) Successful in 1m59s
build-and-release / build (fitness-trainers) (push) Successful in 3m2s
build-and-release / build (jobs) (push) Successful in 1m53s
build-and-release / build (job-seekers) (push) Successful in 2m20s
build-and-release / build (makeup-artists) (push) Successful in 2m42s
build-and-release / build (social-media-managers) (push) Successful in 1m49s
build-and-release / build (payments) (push) Successful in 3m21s
build-and-release / build-db-migrate (push) Successful in 9s
build-and-release / build (photographers) (push) Successful in 2m38s
backend-integration-tests / ai-credits (push) Successful in 47s
build-and-release / build (tutors) (push) Successful in 2m51s
build-and-release / build (ugc-content-creators) (push) Successful in 2m55s
build-and-release / build (video-editors) (push) Successful in 2m43s
build-and-release / build (users) (push) Successful in 4m42s

Following up on the job-expiry 'companies' typo found in the same log
sweep: apps/users/src/ai_subscription.rs (upgrade_plan,
schedule_downgrade, apply_scheduled_downgrades, cancel_subscription,
start_trial, expire_trials, get_subscription_history) and the matching
cron tasks were written entirely against schema that was never
migrated:
  - user_ai_subscriptions.downgrade_scheduled_to/is_trial/trial_days/
    trial_ends_at don't exist
  - ai_subscription_history doesn't exist as a table at all, despite
    6 separate INSERT/UPDATE/SELECT call sites against it

Confirmed live: apply_scheduled_downgrades and expire_trials have been
failing on every hourly cron run. Any user hitting upgrade/downgrade/
cancel/trial-start would 500 the same way.

Schema reconstructed from every call site's actual INSERT/UPDATE/SELECT,
not guessed: from_plan_id nullable (LEFT JOIN'd, never NOT NULL in any
insert), to_plan_id NOT NULL (always bound, INNER JOIN'd),
proration_credits/proration_days_remaining default (only upgrade_plan
binds them), created_by nullable (expire_trials never binds it).

Also fixed a real, unrelated bug found in the same cron-log sweep:
apps/cron/src/tasks/jobs.rs's expire_stale_jobs referenced a
nonexistent 'companies' table (real name: company_profiles) -- also
failing every hourly run, meaning LIVE jobs past expiry were never
being flipped to EXPIRED.

Verified against a live Postgres by calling the actual production
functions (not simulated SQL) end-to-end: upgrade with proration +
history, schedule-then-real-cron-applied downgrade, cancellation,
trial-start-then-real-cron-expiry, and the joined history read-back.
All 5 passed.
This commit is contained in:
Ashwin Kumar Sivakumar 2026-08-18 04:53:30 +05:30
parent e7c1edac0d
commit 6defd52016
2 changed files with 72 additions and 0 deletions

View file

@ -0,0 +1,11 @@
BEGIN;
DROP TABLE IF EXISTS ai_subscription_history;
ALTER TABLE user_ai_subscriptions
DROP COLUMN IF EXISTS downgrade_scheduled_to,
DROP COLUMN IF EXISTS is_trial,
DROP COLUMN IF EXISTS trial_days,
DROP COLUMN IF EXISTS trial_ends_at;
COMMIT;

View file

@ -0,0 +1,61 @@
-- apps/users/src/ai_subscription.rs (upgrade_plan, schedule_downgrade,
-- apply_scheduled_downgrades, cancel_subscription, start_trial,
-- expire_trials, get_subscription_history -- the whole plan-change/trial
-- lifecycle for AI credits) and the matching cron tasks
-- (apps/cron/src/tasks/ai_credits.rs::apply_scheduled_downgrades/
-- expire_trials) were written entirely against columns and a table that
-- were never actually migrated:
-- - user_ai_subscriptions.downgrade_scheduled_to / is_trial / trial_days
-- / trial_ends_at don't exist
-- - ai_subscription_history doesn't exist as a table at all
--
-- Confirmed live: apply_scheduled_downgrades and expire_trials have been
-- failing on every single hourly cron run ("column ... does not exist").
-- Any user hitting upgrade/downgrade/cancel/trial-start through
-- apps/users/src/ai_subscription.rs would 500 the same way.
--
-- Schema below is reconstructed from every INSERT/UPDATE/SELECT against
-- these in ai_subscription.rs -- not a guess: from_plan_id is nullable
-- (upgrade_plan's LEFT JOIN in get_subscription_history, and the column
-- is never NOT NULL in any INSERT), to_plan_id is always bound and
-- INNER JOINed so NOT NULL, proration_credits/proration_days_remaining
-- are only bound by upgrade_plan (every other call site omits them, so
-- they need defaults), created_by is only bound by upgrade_plan/
-- schedule_downgrade/cancel_subscription (expire_trials omits it, so
-- nullable).
BEGIN;
ALTER TABLE user_ai_subscriptions
ADD COLUMN IF NOT EXISTS downgrade_scheduled_to UUID REFERENCES ai_plans(id),
ADD COLUMN IF NOT EXISTS is_trial BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS trial_days INTEGER,
ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS ai_subscription_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
from_plan_id UUID REFERENCES ai_plans(id),
to_plan_id UUID NOT NULL REFERENCES ai_plans(id),
change_type VARCHAR(20) NOT NULL,
proration_credits INTEGER NOT NULL DEFAULT 0,
proration_days_remaining INTEGER,
effective_at TIMESTAMPTZ NOT NULL,
created_by UUID REFERENCES users(id),
status VARCHAR(20) NOT NULL DEFAULT 'completed',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ai_subscription_history_user
ON ai_subscription_history(user_id, created_at DESC);
-- apply_scheduled_downgrades' UPDATE closes out the matching 'scheduled'
-- row by (user_id, change_type, status) with no id to key off of --
-- guard against ever matching more than one in-flight scheduled downgrade
-- per user at a time (schedule_downgrade itself has no such guard either,
-- but this index at least makes the lookup correct/fast and documents the
-- assumption).
CREATE INDEX IF NOT EXISTS idx_ai_subscription_history_pending_downgrade
ON ai_subscription_history(user_id, change_type, status)
WHERE change_type = 'downgrade' AND status = 'scheduled';
COMMIT;