mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Add post-trial inactive Family cleanup with data archival (#1199)
* Add post-trial inactive family cleanup with data archival Families that expire their trial without subscribing now get cleaned up daily. Empty families (no accounts) are destroyed immediately after a 14-day grace period. Families with meaningful data (12+ transactions, some recent) get their data exported as NDJSON/ZIP to an ArchivedExport record before deletion, downloadable via a token-based URL for 90 days. - Add InactiveFamilyCleanerJob (scheduled daily at 4 AM, managed mode only) - Add ArchivedExport model with token-based downloads - Add inactive_trial_for_cleanup scope and requires_data_archive? to Family - Extend DataCleanerJob to purge expired archived exports - Add ArchivedExportsController for unauthenticated token downloads https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix Brakeman redirect warning in ArchivedExportsController Use rails_blob_path instead of redirecting directly to the ActiveStorage attachment, which avoids the allow_other_host: true open redirect. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Update schema.rb with archived_exports table Add the archived_exports table definition to schema.rb to match the pending migration, unblocking CI tests. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix broken CI tests for ArchivedExports and InactiveFamilyCleaner - ArchivedExportsController 404 test: use assert_response :not_found instead of assert_raises since Rails rescues RecordNotFound in integration tests and returns a 404 response. - InactiveFamilyCleanerJob test: remove assert_no_difference on Family.count since the inactive_trial fixture gets cleaned up by the job. The test intent is to verify the active family survives, which is checked by assert Family.exists?. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Wrap ArchivedExport creation in a transaction Ensure the ArchivedExport record and its file attachment succeed atomically. If the attach fails, the transaction rolls back so no orphaned record is left without an export file. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Store only a digest of the download token for ArchivedExport Replace plaintext download_token column with download_token_digest (SHA-256 hex). The raw token is generated via SecureRandom on create, exposed transiently via attr_reader for use in emails/logs, and only its digest is persisted. Lookup uses find_by_download_token! which digests the incoming token before querying. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Remove raw download token from cleanup job logs Log a truncated digest prefix instead of the raw token, which is the sole credential for the unauthenticated download endpoint. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ * Fix empty assert_no_difference block in cleaner job test Wrap the perform_now call with both assertions so the ArchivedExport.count check actually exercises the job. https://claude.ai/code/session_01LR3Vo83R5s5SczYe6T33dQ --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
13
app/controllers/archived_exports_controller.rb
Normal file
13
app/controllers/archived_exports_controller.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
64
app/jobs/inactive_family_cleaner_job.rb
Normal file
64
app/jobs/inactive_family_cleaner_job.rb
Normal file
@@ -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
|
||||
29
app/models/archived_export.rb
Normal file
29
app/models/archived_export.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
15
db/migrate/20260314131357_create_archived_exports.rb
Normal file
15
db/migrate/20260314131357_create_archived_exports.rb
Normal file
@@ -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
|
||||
13
db/schema.rb
generated
13
db/schema.rb
generated
@@ -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
|
||||
|
||||
57
test/controllers/archived_exports_controller_test.rb
Normal file
57
test/controllers/archived_exports_controller_test.rb
Normal file
@@ -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
|
||||
4
test/fixtures/families.yml
vendored
4
test/fixtures/families.yml
vendored
@@ -3,3 +3,7 @@ empty:
|
||||
|
||||
dylan_family:
|
||||
name: The Dylan Family
|
||||
|
||||
inactive_trial:
|
||||
name: Inactive Trial Family
|
||||
created_at: <%= 90.days.ago %>
|
||||
|
||||
9
test/fixtures/subscriptions.yml
vendored
9
test/fixtures/subscriptions.yml
vendored
@@ -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 %>
|
||||
|
||||
13
test/fixtures/users.yml
vendored
13
test/fixtures/users.yml
vendored
@@ -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
|
||||
|
||||
149
test/jobs/inactive_family_cleaner_job_test.rb
Normal file
149
test/jobs/inactive_family_cleaner_job_test.rb
Normal file
@@ -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
|
||||
70
test/models/archived_export_test.rb
Normal file
70
test/models/archived_export_test.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user