2024-02-02 09:05:04 -06:00
class Family < ApplicationRecord
2024-11-15 13:49:37 -05:00
include Plaidable , Syncable
2025-02-03 11:19:56 -05:00
DATE_FORMATS = [
[ " MM-DD-YYYY " , " %m-%d-%Y " ] ,
[ " DD.MM.YYYY " , " %d.%m.%Y " ] ,
[ " DD-MM-YYYY " , " %d-%m-%Y " ] ,
[ " YYYY-MM-DD " , " %Y-%m-%d " ] ,
[ " DD/MM/YYYY " , " %d/%m/%Y " ] ,
[ " YYYY/MM/DD " , " %Y/%m/%d " ] ,
[ " MM/DD/YYYY " , " %m/%d/%Y " ] ,
[ " D/MM/YYYY " , " %e/%m/%Y " ] ,
[ " YYYY.MM.DD " , " %Y.%m.%d " ]
] . freeze
2024-10-23 11:20:55 -04:00
2024-10-02 12:07:56 -04:00
include Providable
2024-02-02 09:05:04 -06:00
has_many :users , dependent : :destroy
2024-11-01 10:23:27 -05:00
has_many :invitations , dependent : :destroy
2024-05-23 08:09:33 -04:00
has_many :tags , dependent : :destroy
2024-02-02 09:05:04 -06:00
has_many :accounts , dependent : :destroy
2024-10-01 10:47:59 -04:00
has_many :imports , dependent : :destroy
2024-07-01 10:49:43 -04:00
has_many :transactions , through : :accounts
has_many :entries , through : :accounts
2024-06-20 08:15:09 -04:00
has_many :categories , dependent : :destroy
2024-06-20 08:38:59 -04:00
has_many :merchants , dependent : :destroy
2024-08-16 12:13:48 -04:00
has_many :issues , through : :accounts
2024-11-27 16:01:50 -05:00
has_many :holdings , through : :accounts
2024-11-15 13:49:37 -05:00
has_many :plaid_items , dependent : :destroy
2025-01-16 14:36:37 -05:00
has_many :budgets , dependent : :destroy
has_many :budget_categories , through : :budgets
2024-03-04 08:31:22 -05:00
2024-10-02 14:02:17 -04:00
validates :locale , inclusion : { in : I18n . available_locales . map ( & :to_s ) }
2025-02-03 11:19:56 -05:00
validates :date_format , inclusion : { in : DATE_FORMATS . map ( & :last ) }
2024-10-02 14:02:17 -04:00
2024-11-15 13:49:37 -05:00
def sync_data ( start_date : nil )
update! ( last_synced_at : Time . current )
accounts . manual . each do | account |
2025-02-04 14:22:44 -05:00
account . sync_later ( start_date : start_date )
2024-11-15 13:49:37 -05:00
end
plaid_items . each do | plaid_item |
2025-02-04 14:22:44 -05:00
plaid_item . sync_later ( start_date : start_date )
2024-11-15 13:49:37 -05:00
end
end
2024-11-20 11:01:52 -05:00
def post_sync
broadcast_refresh
end
2024-11-15 13:49:37 -05:00
def syncing?
2025-01-31 19:08:21 -05:00
Sync . where (
" (syncable_type = 'Family' AND syncable_id = ?) OR
( syncable_type = 'Account' AND syncable_id IN ( SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL ) ) OR
( syncable_type = 'PlaidItem' AND syncable_id IN ( SELECT id FROM plaid_items WHERE family_id = ?) ) " ,
id , id , id
) . where ( status : [ " pending " , " syncing " ] ) . exists?
2024-11-15 13:49:37 -05:00
end
2025-01-31 17:04:26 -05:00
def eu?
country != " US " && country != " CA "
end
2025-01-31 12:13:58 -06:00
def get_link_token ( webhooks_url : , redirect_url : , accountable_type : nil , region : :us )
2025-01-31 17:04:26 -05:00
provider = if region . to_sym == :eu
self . class . plaid_eu_provider
else
self . class . plaid_us_provider
2025-01-31 12:13:58 -06:00
end
2025-02-03 17:18:49 +01:00
# early return when no provider
return nil unless provider
2025-01-31 12:13:58 -06:00
provider . get_link_token (
2024-11-15 13:49:37 -05:00
user_id : id ,
webhooks_url : webhooks_url ,
redirect_url : redirect_url ,
2025-01-31 12:13:58 -06:00
accountable_type : accountable_type ,
2024-11-15 13:49:37 -05:00
) . link_token
end
2025-01-16 14:36:37 -05:00
def income_categories_with_totals ( date : Date . current )
categories_with_stats ( classification : " income " , date : date )
end
def expense_categories_with_totals ( date : Date . current )
categories_with_stats ( classification : " expense " , date : date )
end
def category_stats
CategoryStats . new ( self )
end
def budgeting_stats
BudgetingStats . new ( self )
end
2024-03-11 16:32:13 -04:00
def snapshot ( period = Period . all )
query = accounts . active . joins ( :balances )
2024-06-19 06:52:08 -04:00
. where ( " account_balances.currency = ? " , self . currency )
. select (
" account_balances.currency " ,
" account_balances.date " ,
" SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities " ,
" SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets " ,
" SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth " ,
)
. group ( " account_balances.date, account_balances.currency " )
. order ( " account_balances.date " )
2024-03-11 16:32:13 -04:00
2024-03-21 13:39:10 -04:00
query = query . where ( " account_balances.date >= ? " , period . date_range . begin ) if period . date_range . begin
query = query . where ( " account_balances.date <= ? " , period . date_range . end ) if period . date_range . end
2024-03-19 09:10:40 -04:00
result = query . to_a
2024-03-11 16:32:13 -04:00
{
2024-03-19 09:10:40 -04:00
asset_series : TimeSeries . new ( result . map { | r | { date : r . date , value : Money . new ( r . assets , r . currency ) } } ) ,
2025-02-11 21:18:22 +01:00
liability_series : TimeSeries . new ( result . map { | r | { date : r . date , value : Money . new ( r . liabilities , r . currency ) } } , favorable_direction : " down " ) ,
2024-03-19 09:10:40 -04:00
net_worth_series : TimeSeries . new ( result . map { | r | { date : r . date , value : Money . new ( r . net_worth , r . currency ) } } )
2024-03-11 16:32:13 -04:00
}
2024-03-04 08:31:22 -05:00
end
2024-04-24 13:34:50 +01:00
def snapshot_account_transactions
period = Period . last_30_days
2025-01-07 09:41:24 -05:00
results = accounts . active
. joins ( :entries )
. joins ( " LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id) " )
2024-07-01 10:49:43 -04:00
. select (
" accounts.* " ,
" COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending " ,
" COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income "
)
. where ( " account_entries.date >= ? " , period . date_range . begin )
. where ( " account_entries.date <= ? " , period . date_range . end )
2025-01-17 15:48:16 +01:00
. where ( " account_entries.entryable_type = 'Account::Transaction' " )
2025-01-07 09:41:24 -05:00
. where ( " transfers.id IS NULL " )
2024-08-20 15:44:32 -04:00
. group ( " accounts.id " )
. having ( " SUM(ABS(account_entries.amount)) > 0 " )
2024-07-01 10:49:43 -04:00
. to_a
2024-04-24 13:34:50 +01:00
2024-04-24 15:02:22 +01:00
results . each do | r |
r . define_singleton_method ( :savings_rate ) do
( income - spending ) / income
end
end
2024-04-24 13:34:50 +01:00
{
top_spenders : results . sort_by ( & :spending ) . select { | a | a . spending > 0 } . reverse ,
2024-04-24 15:02:22 +01:00
top_earners : results . sort_by ( & :income ) . select { | a | a . income > 0 } . reverse ,
top_savers : results . sort_by { | a | a . savings_rate } . reverse
2024-04-24 13:34:50 +01:00
}
end
def snapshot_transactions
2025-01-07 09:41:24 -05:00
candidate_entries = entries . account_transactions . incomes_and_expenses
2024-07-01 10:49:43 -04:00
rolling_totals = Account :: Entry . daily_rolling_totals ( candidate_entries , self . currency , period : Period . last_30_days )
2024-04-24 13:34:50 +01:00
spending = [ ]
income = [ ]
2024-04-24 15:02:22 +01:00
savings = [ ]
2024-04-24 13:34:50 +01:00
rolling_totals . each do | r |
spending << {
date : r . date ,
value : Money . new ( r . rolling_spend , self . currency )
}
income << {
date : r . date ,
value : Money . new ( r . rolling_income , self . currency )
}
2024-04-24 15:02:22 +01:00
savings << {
date : r . date ,
2025-01-17 17:21:00 +01:00
value : r . rolling_income != 0 ? ( ( r . rolling_income - r . rolling_spend ) / r . rolling_income ) : 0 . to_d
2024-04-24 15:02:22 +01:00
}
2024-04-24 13:34:50 +01:00
end
{
income_series : TimeSeries . new ( income , favorable_direction : " up " ) ,
2024-04-24 15:02:22 +01:00
spending_series : TimeSeries . new ( spending , favorable_direction : " down " ) ,
savings_rate_series : TimeSeries . new ( savings , favorable_direction : " up " )
2024-04-24 13:34:50 +01:00
}
end
2024-03-11 16:32:13 -04:00
def net_worth
2024-03-21 13:39:10 -04:00
assets - liabilities
2024-03-04 08:31:22 -05:00
end
2024-03-11 16:32:13 -04:00
def assets
2024-07-08 09:04:59 -04:00
Money . new ( accounts . active . assets . map { | account | account . balance_money . exchange_to ( currency , fallback_rate : 0 ) } . sum , currency )
2024-03-04 08:31:22 -05:00
end
2024-03-11 16:32:13 -04:00
def liabilities
2024-07-08 09:04:59 -04:00
Money . new ( accounts . active . liabilities . map { | account | account . balance_money . exchange_to ( currency , fallback_rate : 0 ) } . sum , currency )
2024-03-04 08:31:22 -05:00
end
2024-04-04 23:00:12 +02:00
2024-10-02 12:07:56 -04:00
def synth_usage
self . class . synth_provider & . usage
end
2024-10-08 14:37:47 -05:00
2024-11-26 07:45:00 -06:00
def synth_overage?
2024-12-04 00:36:59 +05:30
self . class . synth_provider & . usage & . utilization . to_i > = 100
end
def synth_valid?
self . class . synth_provider & . healthy?
2024-11-26 07:45:00 -06:00
end
2024-10-08 14:37:47 -05:00
def subscribed?
2024-10-24 11:02:27 -04:00
stripe_subscription_status == " active "
2024-10-08 14:37:47 -05:00
end
def primary_user
users . order ( :created_at ) . first
end
2025-01-16 14:36:37 -05:00
def oldest_entry_date
entries . order ( :date ) . first & . date || Date . current
end
2025-02-03 09:04:39 -06:00
def active_accounts_count
accounts . active . count
end
2025-01-16 14:36:37 -05:00
private
CategoriesWithTotals = Struct . new ( :total_money , :category_totals , keyword_init : true )
CategoryWithStats = Struct . new ( :category , :amount_money , :percentage , keyword_init : true )
def categories_with_stats ( classification : , date : Date . current )
totals = category_stats . month_category_totals ( date : date )
classified_totals = totals . category_totals . select { | t | t . classification == classification }
if classification == " income "
total = totals . total_income
categories_scope = categories . incomes
else
total = totals . total_expense
categories_scope = categories . expenses
end
categories_with_uncategorized = categories_scope + [ categories_scope . uncategorized ]
CategoriesWithTotals . new (
total_money : Money . new ( total , currency ) ,
category_totals : categories_with_uncategorized . map do | category |
ct = classified_totals . find { | ct | ct . category_id == category & . id }
CategoryWithStats . new (
category : category ,
amount_money : Money . new ( ct & . amount || 0 , currency ) ,
percentage : ct & . percentage || 0
)
end
)
end
2024-02-02 09:05:04 -06:00
end