mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 12:54:04 +00:00
* 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>
117 lines
3.1 KiB
Ruby
117 lines
3.1 KiB
Ruby
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
|
|
primary_admin = users.admin.order(:created_at).first || users.super_admin.order(:created_at).first
|
|
|
|
unless primary_admin.present?
|
|
raise "No primary admin found for family #{id}. This is an invalid data state and should never occur."
|
|
end
|
|
|
|
primary_admin.email
|
|
end
|
|
|
|
def upgrade_required?
|
|
return false if self_hoster?
|
|
return false if subscription&.active? || subscription&.trialing?
|
|
|
|
true
|
|
end
|
|
|
|
def can_start_trial?
|
|
subscription&.trial_ends_at.blank?
|
|
end
|
|
|
|
def start_trial_subscription!
|
|
create_subscription!(
|
|
status: "trialing",
|
|
trial_ends_at: Subscription.new_trial_ends_at
|
|
)
|
|
end
|
|
|
|
def trialing?
|
|
subscription&.trialing? && days_left_in_trial.positive?
|
|
end
|
|
|
|
def has_active_subscription?
|
|
subscription&.active?
|
|
end
|
|
|
|
def can_manage_subscription?
|
|
stripe_customer_id.present?
|
|
end
|
|
|
|
def needs_subscription?
|
|
subscription.nil? && !self_hoster?
|
|
end
|
|
|
|
def next_payment_date
|
|
subscription&.current_period_ends_at
|
|
end
|
|
|
|
def subscription_pending_cancellation?
|
|
subscription&.pending_cancellation?
|
|
end
|
|
|
|
def start_subscription!(stripe_subscription_id)
|
|
if subscription.present?
|
|
subscription.update!(status: "active", stripe_id: stripe_subscription_id)
|
|
else
|
|
create_subscription!(status: "active", stripe_id: stripe_subscription_id)
|
|
end
|
|
end
|
|
|
|
def days_left_in_trial
|
|
return -1 unless subscription.present?
|
|
((subscription.trial_ends_at - Time.current).to_i / 86400) + 1
|
|
end
|
|
|
|
def percentage_of_trial_remaining
|
|
return 0 unless subscription.present?
|
|
(days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
|
|
end
|
|
|
|
def percentage_of_trial_completed
|
|
return 0 unless subscription.present?
|
|
(1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
|
|
end
|
|
|
|
def sync_trial_status!
|
|
if subscription&.status == "trialing" && days_left_in_trial < 0
|
|
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
|