create type schedule_frequency as enum ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'); create type schedule_end_condition as enum ('NEVER', 'AFTER_COUNT', 'BY_DATE'); create table schedules( id bigserial primary key, group_id bigint not null references groups(id) on delete cascade, created_by bigint not null references users(id), entry_type entry_type not null default 'SPENDING', amount_dollars numeric(12,2) not null check (amount_dollars >= 0), necessity spending_necessity not null, purchase_type text not null, notes text, starts_on date not null, frequency schedule_frequency not null, interval_count integer not null default 1 check (interval_count > 0), end_condition schedule_end_condition not null default 'NEVER', end_count integer, end_date date, next_run_on date not null, last_run_on date, run_count integer not null default 0, is_active boolean not null default true, legacy_source_entry_id bigint references entries(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index schedules_group_next_run_idx on schedules(group_id, next_run_on); create index schedules_created_by_idx on schedules(created_by); create table schedule_tags( schedule_id bigint not null references schedules(id) on delete cascade, tag_id bigint not null references tags(id) on delete cascade, created_at timestamptz not null default now(), primary key (schedule_id, tag_id) ); create index schedule_tags_schedule_idx on schedule_tags(schedule_id); create index schedule_tags_tag_idx on schedule_tags(tag_id); alter table entries add column source_schedule_id bigint references schedules(id) on delete set null; create unique index entries_schedule_occurrence_unique on entries(source_schedule_id, occurred_at) where source_schedule_id is not null; with legacy_recurring as ( select e.id, e.group_id, e.created_by, e.entry_type, e.amount_dollars, e.necessity, e.purchase_type, e.notes, e.occurred_at, case when e.frequency::text = 'BIWEEKLY' then 'WEEKLY'::schedule_frequency when e.frequency::text = 'QUARTERLY' then 'MONTHLY'::schedule_frequency when e.frequency::text in ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY') then e.frequency::text::schedule_frequency else 'MONTHLY'::schedule_frequency end as schedule_frequency, case when e.frequency::text = 'BIWEEKLY' then greatest(coalesce(e.interval_count, 1), 1) * 2 when e.frequency::text = 'QUARTERLY' then greatest(coalesce(e.interval_count, 1), 1) * 3 else greatest(coalesce(e.interval_count, 1), 1) end as safe_interval_count, coalesce(e.end_condition::text, 'NEVER')::schedule_end_condition as schedule_end_condition, e.end_count, e.end_date, coalesce(e.next_run_at, e.occurred_at) as legacy_base_date from entries e where e.is_recurring = true ), inserted_schedules as ( insert into schedules( group_id, created_by, entry_type, amount_dollars, necessity, purchase_type, notes, starts_on, frequency, interval_count, end_condition, end_count, end_date, next_run_on, last_run_on, run_count, is_active, legacy_source_entry_id ) select lr.group_id, lr.created_by, lr.entry_type, lr.amount_dollars, lr.necessity, lr.purchase_type, lr.notes, lr.legacy_base_date as starts_on, lr.schedule_frequency, lr.safe_interval_count, lr.schedule_end_condition, lr.end_count, lr.end_date, case when lr.schedule_frequency = 'DAILY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' day')::interval)::date when lr.schedule_frequency = 'WEEKLY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' week')::interval)::date when lr.schedule_frequency = 'MONTHLY' then (lr.legacy_base_date + (lr.safe_interval_count::text || ' month')::interval)::date else (lr.legacy_base_date + (lr.safe_interval_count::text || ' year')::interval)::date end as next_run_on, lr.legacy_base_date as last_run_on, 1 as run_count, case when lr.schedule_end_condition = 'AFTER_COUNT' and coalesce(lr.end_count, 0) <= 1 then false when lr.schedule_end_condition = 'BY_DATE' and lr.end_date is not null and lr.end_date < lr.legacy_base_date then false else true end as is_active, lr.id as legacy_source_entry_id from legacy_recurring lr returning id, legacy_source_entry_id ) insert into schedule_tags(schedule_id, tag_id) select i.id, et.tag_id from inserted_schedules i join entry_tags et on et.entry_id = i.legacy_source_entry_id on conflict do nothing; update entries set is_recurring = false, frequency = null, interval_count = 1, end_condition = null, end_count = null, end_date = null, next_run_at = null, last_executed_at = null where is_recurring = true;