diff --git a/app/controllers/archived_exports_controller.rb b/app/controllers/archived_exports_controller.rb new file mode 100644 index 000000000..f626141a6 --- /dev/null +++ b/app/controllers/archived_exports_controller.rb @@ -0,0 +1,13 @@ +class ArchivedExportsController < ApplicationController + skip_authentication + + def show + export = ArchivedExport.find_by_download_token!(params[:token]) + + if export.downloadable? + redirect_to rails_blob_path(export.export_file, disposition: "attachment") + else + head :gone + end + end +end diff --git a/app/jobs/data_cleaner_job.rb b/app/jobs/data_cleaner_job.rb index 8cb22f283..becf0fba5 100644 --- a/app/jobs/data_cleaner_job.rb +++ b/app/jobs/data_cleaner_job.rb @@ -3,6 +3,7 @@ class DataCleanerJob < ApplicationJob def perform clean_old_merchant_associations + clean_expired_archived_exports end private @@ -14,4 +15,10 @@ class DataCleanerJob < ApplicationJob Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} old merchant associations") if deleted_count > 0 end + + def clean_expired_archived_exports + deleted_count = ArchivedExport.expired.destroy_all.count + + Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} expired archived exports") if deleted_count > 0 + end end diff --git a/app/jobs/inactive_family_cleaner_job.rb b/app/jobs/inactive_family_cleaner_job.rb new file mode 100644 index 000000000..118d1c4c1 --- /dev/null +++ b/app/jobs/inactive_family_cleaner_job.rb @@ -0,0 +1,64 @@ +class InactiveFamilyCleanerJob < ApplicationJob + queue_as :scheduled + + BATCH_SIZE = 500 + ARCHIVE_EXPIRY = 90.days + + def perform(dry_run: false) + return unless Rails.application.config.app_mode.managed? + + families = Family.inactive_trial_for_cleanup.limit(BATCH_SIZE) + count = families.count + + if count == 0 + Rails.logger.info("InactiveFamilyCleanerJob: No inactive families to clean up") + return + end + + Rails.logger.info("InactiveFamilyCleanerJob: Found #{count} inactive families to clean up#{' (dry run)' if dry_run}") + + families.find_each do |family| + if family.requires_data_archive? + if dry_run + Rails.logger.info("InactiveFamilyCleanerJob: Would archive data for family #{family.id}") + else + archive_family_data(family) + end + end + + if dry_run + Rails.logger.info("InactiveFamilyCleanerJob: Would destroy family #{family.id} (created: #{family.created_at})") + else + Rails.logger.info("InactiveFamilyCleanerJob: Destroying family #{family.id} (created: #{family.created_at})") + family.destroy + end + end + + Rails.logger.info("InactiveFamilyCleanerJob: Completed cleanup of #{count} families#{' (dry run)' if dry_run}") + end + + private + + def archive_family_data(family) + export_data = Family::DataExporter.new(family).generate_export + email = family.users.order(:created_at).first&.email + + ActiveRecord::Base.transaction do + archive = ArchivedExport.create!( + email: email || "unknown", + family_name: family.name, + expires_at: ARCHIVE_EXPIRY.from_now + ) + + archive.export_file.attach( + io: export_data, + filename: "sure_archive_#{family.id}.zip", + content_type: "application/zip" + ) + + raise ActiveRecord::Rollback, "File attach failed" unless archive.export_file.attached? + + Rails.logger.info("InactiveFamilyCleanerJob: Archived data for family #{family.id} (email: #{email}, token_digest: #{archive.download_token_digest.first(8)}...)") + end + end +end diff --git a/app/models/archived_export.rb b/app/models/archived_export.rb new file mode 100644 index 000000000..fb0f48181 --- /dev/null +++ b/app/models/archived_export.rb @@ -0,0 +1,29 @@ +class ArchivedExport < ApplicationRecord + has_one_attached :export_file, dependent: :purge_later + + scope :expired, -> { where(expires_at: ...Time.current) } + + attr_reader :download_token + + before_create :set_download_token_digest + + def downloadable? + expires_at > Time.current && export_file.attached? + end + + def self.find_by_download_token!(token) + find_by!(download_token_digest: digest_token(token)) + end + + def self.digest_token(token) + OpenSSL::Digest::SHA256.hexdigest(token) + end + + private + + def set_download_token_digest + raw_token = SecureRandom.urlsafe_base64(24) + @download_token = raw_token + self.download_token_digest = self.class.digest_token(raw_token) + end +end diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb index de75bbe0c..9ac267f6c 100644 --- a/app/models/family/subscribeable.rb +++ b/app/models/family/subscribeable.rb @@ -1,8 +1,27 @@ module Family::Subscribeable extend ActiveSupport::Concern + CLEANUP_GRACE_PERIOD = 14.days + ARCHIVE_TRANSACTION_THRESHOLD = 12 + ARCHIVE_RECENT_ACTIVITY_WINDOW = 14.days + included do has_one :subscription, dependent: :destroy + + scope :inactive_trial_for_cleanup, -> { + cutoff_with_sub = CLEANUP_GRACE_PERIOD.ago + cutoff_without_sub = (Subscription::TRIAL_DAYS.days + CLEANUP_GRACE_PERIOD).ago + + expired_trial = left_joins(:subscription) + .where(subscriptions: { status: [ "paused", "trialing" ] }) + .where(subscriptions: { trial_ends_at: ...cutoff_with_sub }) + + no_subscription = left_joins(:subscription) + .where(subscriptions: { id: nil }) + .where(families: { created_at: ...cutoff_without_sub }) + + where(id: expired_trial).or(where(id: no_subscription)) + } end def payment_email @@ -85,4 +104,13 @@ module Family::Subscribeable subscription.update!(status: "paused") end end + + def requires_data_archive? + return false unless transactions.count > ARCHIVE_TRANSACTION_THRESHOLD + + trial_end = subscription&.trial_ends_at || (created_at + Subscription::TRIAL_DAYS.days) + recent_window_start = trial_end - ARCHIVE_RECENT_ACTIVITY_WINDOW + + entries.where(date: recent_window_start..trial_end).exists? + end end diff --git a/config/routes.rb b/config/routes.rb index 3cf33b146..8fede6930 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -125,6 +125,8 @@ Rails.application.routes.draw do end end + get "exports/archive/:token", to: "archived_exports#show", as: :archived_export + get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" patch "dashboard/preferences", to: "pages#update_preferences" diff --git a/config/schedule.yml b/config/schedule.yml index c0d324408..74ac99122 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -29,4 +29,10 @@ clean_data: cron: "0 3 * * *" # daily at 3:00 AM class: "DataCleanerJob" queue: "scheduled" - description: "Cleans up old data (e.g., expired merchant associations)" + description: "Cleans up old data (e.g., expired merchant associations, expired archived exports)" + +clean_inactive_families: + cron: "0 4 * * *" # daily at 4:00 AM + class: "InactiveFamilyCleanerJob" + queue: "scheduled" + description: "Archives and destroys families that expired their trial without subscribing (managed mode only)" diff --git a/db/migrate/20260314131357_create_archived_exports.rb b/db/migrate/20260314131357_create_archived_exports.rb new file mode 100644 index 000000000..1fecb099e --- /dev/null +++ b/db/migrate/20260314131357_create_archived_exports.rb @@ -0,0 +1,15 @@ +class CreateArchivedExports < ActiveRecord::Migration[7.2] + def change + create_table :archived_exports, id: :uuid do |t| + t.string :email, null: false + t.string :family_name + t.string :download_token_digest, null: false + t.datetime :expires_at, null: false + + t.timestamps + end + + add_index :archived_exports, :download_token_digest, unique: true + add_index :archived_exports, :expires_at + end +end diff --git a/db/schema.rb b/db/schema.rb index b56fb9f41..ae425569d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_03_14_120000) do +ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -125,6 +125,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_120000) do t.index ["user_id"], name: "index_api_keys_on_user_id" end + create_table "archived_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "email", null: false + t.string "family_name" + t.string "download_token_digest", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["download_token_digest"], name: "index_archived_exports_on_download_token_digest", unique: true + t.index ["expires_at"], name: "index_archived_exports_on_expires_at" + end + create_table "balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.date "date", null: false diff --git a/test/controllers/archived_exports_controller_test.rb b/test/controllers/archived_exports_controller_test.rb new file mode 100644 index 000000000..0e23cb286 --- /dev/null +++ b/test/controllers/archived_exports_controller_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class ArchivedExportsControllerTest < ActionDispatch::IntegrationTest + test "redirects to file with valid token" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get archived_export_path(token: archive.download_token) + assert_response :redirect + end + + test "returns 410 gone for expired token" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 1.day.ago + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + get archived_export_path(token: archive.download_token) + assert_response :gone + end + + test "returns 404 for invalid token" do + get archived_export_path(token: "nonexistent-token") + assert_response :not_found + end + + test "does not require authentication" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test zip content"), + filename: "test.zip", + content_type: "application/zip" + ) + + # No sign_in call - should still work + get archived_export_path(token: archive.download_token) + assert_response :redirect + end +end diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml index 10d5bd184..be4598bae 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -3,3 +3,7 @@ empty: dylan_family: name: The Dylan Family + +inactive_trial: + name: Inactive Trial Family + created_at: <%= 90.days.ago %> diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml index 333ba7fe7..7d7b7c612 100644 --- a/test/fixtures/subscriptions.yml +++ b/test/fixtures/subscriptions.yml @@ -1,9 +1,14 @@ active: - family: dylan_family - status: active + family: dylan_family + status: active stripe_id: "test_1234567890" trialing: family: empty status: trialing trial_ends_at: <%= 12.days.from_now %> + +expired_trial: + family: inactive_trial + status: paused + trial_ends_at: <%= 45.days.ago %> diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index dc55cfc0f..109b78a13 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -77,6 +77,19 @@ intro_user: show_ai_sidebar: false ui_layout: intro +inactive_trial_user: + family: inactive_trial + first_name: Inactive + last_name: User + email: inactive@example.com + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + role: admin + onboarded_at: <%= 90.days.ago %> + ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard + # SSO-only user: created via JIT provisioning, no local password sso_only: family: empty diff --git a/test/jobs/inactive_family_cleaner_job_test.rb b/test/jobs/inactive_family_cleaner_job_test.rb new file mode 100644 index 000000000..5a34307f4 --- /dev/null +++ b/test/jobs/inactive_family_cleaner_job_test.rb @@ -0,0 +1,149 @@ +require "test_helper" + +class InactiveFamilyCleanerJobTest < ActiveJob::TestCase + setup do + @inactive_family = families(:inactive_trial) + @inactive_user = users(:inactive_trial_user) + Rails.application.config.stubs(:app_mode).returns("managed".inquiry) + end + + test "skips in self-hosted mode" do + Rails.application.config.stubs(:app_mode).returns("self_hosted".inquiry) + + assert_no_difference "Family.count" do + InactiveFamilyCleanerJob.perform_now + end + end + + test "destroys empty post-trial family with no accounts" do + assert_equal 0, @inactive_family.accounts.count + + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + + assert_not Family.exists?(@inactive_family.id) + end + + test "does not create archive for family with no accounts" do + assert_no_difference "ArchivedExport.count" do + InactiveFamilyCleanerJob.perform_now + end + end + + test "destroys family with accounts but few transactions" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + # Add only 5 transactions (below threshold of 12) + 5.times do |i| + account.entries.create!( + name: "Txn #{i}", date: 50.days.ago + i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_no_difference "ArchivedExport.count" do + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + end + end + + test "archives then destroys family with 12+ recent transactions" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + trial_end = @inactive_family.subscription.trial_ends_at + # Create 15 transactions, some within last 14 days of trial + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_difference "ArchivedExport.count", 1 do + assert_difference "Family.count", -1 do + InactiveFamilyCleanerJob.perform_now + end + end + + archive = ArchivedExport.last + assert_equal "inactive@example.com", archive.email + assert_equal "Inactive Trial Family", archive.family_name + assert archive.export_file.attached? + assert archive.download_token_digest.present? + assert archive.expires_at > 89.days.from_now + end + + test "preserves families with active subscriptions" do + dylan_family = families(:dylan_family) + assert dylan_family.subscription.active? + + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(dylan_family.id) + end + + test "preserves families still within grace period" do + @inactive_family.subscription.update!(trial_ends_at: 5.days.ago) + + initial_count = Family.count + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(@inactive_family.id) + end + + test "destroys families with no subscription created long ago" do + old_family = Family.create!(name: "Abandoned", created_at: 90.days.ago) + old_family.users.create!( + first_name: "Old", last_name: "User", email: "old-abandoned@example.com", + password: "password123", role: :admin, onboarded_at: 90.days.ago, + ai_enabled: true, show_sidebar: true, show_ai_sidebar: true, ui_layout: :dashboard + ) + # No subscription created + + assert_nil old_family.subscription + + InactiveFamilyCleanerJob.perform_now + + assert_not Family.exists?(old_family.id) + end + + test "preserves recently created families with no subscription" do + recent_family = Family.create!(name: "New Family") + recent_family.users.create!( + first_name: "New", last_name: "User", email: "newuser-recent@example.com", + password: "password123", role: :admin, onboarded_at: 1.day.ago, + ai_enabled: true, show_sidebar: true, show_ai_sidebar: true, ui_layout: :dashboard + ) + + InactiveFamilyCleanerJob.perform_now + + assert Family.exists?(recent_family.id) + + # Cleanup + recent_family.destroy + end + + test "dry run does not destroy or archive" do + account = @inactive_family.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + trial_end = @inactive_family.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_no_difference [ "Family.count", "ArchivedExport.count" ] do + InactiveFamilyCleanerJob.perform_now(dry_run: true) + end + + assert Family.exists?(@inactive_family.id) + end +end diff --git a/test/models/archived_export_test.rb b/test/models/archived_export_test.rb new file mode 100644 index 000000000..56485cf9b --- /dev/null +++ b/test/models/archived_export_test.rb @@ -0,0 +1,70 @@ +require "test_helper" + +class ArchivedExportTest < ActiveSupport::TestCase + test "downloadable? returns true when not expired and file attached" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + archive.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert archive.downloadable? + end + + test "downloadable? returns false when expired" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 1.day.ago + ) + archive.export_file.attach( + io: StringIO.new("test content"), + filename: "test.zip", + content_type: "application/zip" + ) + + assert_not archive.downloadable? + end + + test "downloadable? returns false when file not attached" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + + assert_not archive.downloadable? + end + + test "expired scope returns only expired records" do + expired = ArchivedExport.create!( + email: "expired@example.com", + family_name: "Expired", + expires_at: 1.day.ago + ) + active = ArchivedExport.create!( + email: "active@example.com", + family_name: "Active", + expires_at: 30.days.from_now + ) + + results = ArchivedExport.expired + assert_includes results, expired + assert_not_includes results, active + end + + test "generates download_token automatically" do + archive = ArchivedExport.create!( + email: "test@example.com", + family_name: "Test", + expires_at: 30.days.from_now + ) + + assert archive.download_token.present? + end +end diff --git a/test/models/family/subscribeable_test.rb b/test/models/family/subscribeable_test.rb index 0b1aafe71..7fda5ec58 100644 --- a/test/models/family/subscribeable_test.rb +++ b/test/models/family/subscribeable_test.rb @@ -25,4 +25,86 @@ class Family::SubscribeableTest < ActiveSupport::TestCase @family.update!(stripe_customer_id: "") assert_not @family.can_manage_subscription? end + + test "inactive_trial_for_cleanup includes families with expired paused trials" do + inactive = families(:inactive_trial) + results = Family.inactive_trial_for_cleanup + + assert_includes results, inactive + end + + test "inactive_trial_for_cleanup excludes families with active subscriptions" do + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, @family + end + + test "inactive_trial_for_cleanup excludes families within grace period" do + inactive = families(:inactive_trial) + inactive.subscription.update!(trial_ends_at: 5.days.ago) + + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, inactive + end + + test "inactive_trial_for_cleanup includes families with no subscription created long ago" do + old_family = Family.create!(name: "Abandoned", created_at: 90.days.ago) + + results = Family.inactive_trial_for_cleanup + + assert_includes results, old_family + + old_family.destroy + end + + test "inactive_trial_for_cleanup excludes recently created families with no subscription" do + recent_family = Family.create!(name: "New") + + results = Family.inactive_trial_for_cleanup + + assert_not_includes results, recent_family + + recent_family.destroy + end + + test "requires_data_archive? returns false with few transactions" do + inactive = families(:inactive_trial) + assert_not inactive.requires_data_archive? + end + + test "requires_data_archive? returns true with 12+ recent transactions" do + inactive = families(:inactive_trial) + account = inactive.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + trial_end = inactive.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert inactive.requires_data_archive? + end + + test "requires_data_archive? returns false with 12+ transactions but none recent" do + inactive = families(:inactive_trial) + account = inactive.accounts.create!( + name: "Test", currency: "USD", balance: 0, accountable: Depository.new, status: :active + ) + + # All transactions from early in the trial (more than 14 days before trial end) + trial_end = inactive.subscription.trial_ends_at + 15.times do |i| + account.entries.create!( + name: "Txn #{i}", date: trial_end - 30.days - i.days, amount: 10, currency: "USD", + entryable: Transaction.new + ) + end + + assert_not inactive.requires_data_archive? + end end