Skip to content

Commit

Permalink
Account reconciliation
Browse files Browse the repository at this point in the history
  • Loading branch information
jamis committed Apr 25, 2009
1 parent 6b6e3a5 commit 120e981
Show file tree
Hide file tree
Showing 26 changed files with 858 additions and 17 deletions.
3 changes: 2 additions & 1 deletion TODO
Expand Up @@ -18,7 +18,6 @@ FEATURES that would be nice to have someday (in no particular order)
* better 404 and 500 error pages
* searching
* reporting
* account reconciliation
* transaction templates ("saved" or "memorized" transactions)
* scheduled transactions (occur automatically at specified intervals)
* print stylesheet
Expand All @@ -32,3 +31,5 @@ FEATURES that would be nice to have someday (in no particular order)
* make bucket reallocation work from bucket perma
* support for multiple 'aside' buckets in a single account
* graphical icons to replace the textual icons for various actions
* add/edit transactions from the reconciliation view
* statement API
13 changes: 13 additions & 0 deletions app/concerns/categorized_items.rb
@@ -0,0 +1,13 @@
module CategorizedItems
def deposits
@deposits ||= to_a.select { |item| item.amount > 0 }
end

def checks
@checks ||= to_a.select { |item| item.amount < 0 && item.event.check_number.present? }
end

def expenses
@expenses ||= to_a.select { |item| item.amount < 0 && item.event.check_number.blank? }
end
end
54 changes: 54 additions & 0 deletions app/controllers/statements_controller.rb
@@ -0,0 +1,54 @@
class StatementsController < ApplicationController
before_filter :find_account, :only => %w(index new create)
before_filter :find_statement, :only => %w(show edit update destroy)

def index
@statements = account.statements.balanced
end

def new
@statement = account.statements.build(:ending_balance => account.balance,
:occurred_on => Date.today)
end

def create
@statement = account.statements.create(params[:statement])
redirect_to(edit_statement_url(@statement))
end

def show
end

def edit
@uncleared = account.account_items.uncleared(:with => statement, :include => :event)
end

def update
statement.update_attributes(params[:statement])
redirect_to(account)
end

def destroy
statement.destroy
redirect_to(account)
end

protected

attr_reader :uncleared, :statements
helper_method :uncleared, :statements

attr_reader :account, :statement
helper_method :account, :statement

def find_account
@account = Account.find(params[:account_id])
@subscription = user.subscriptions.find(@account.subscription_id)
end

def find_statement
@statement = Statement.find(params[:id])
@account = @statement.account
@subscription = user.subscriptions.find(@account.subscription_id)
end
end
4 changes: 4 additions & 0 deletions app/helpers/application_helper.rb
Expand Up @@ -24,4 +24,8 @@ def application_last_deployed
"(not deployed)"
end
end

def format_cents(amount, options={})
number_to_currency(amount/100.0, options)
end
end
7 changes: 7 additions & 0 deletions app/helpers/statements_helper.rb
@@ -0,0 +1,7 @@
module StatementsHelper
def uncleared_row_class(item)
classes = [cycle('odd', 'even')]
classes << "cleared" if item.statement_id
classes.join(" ")
end
end
4 changes: 3 additions & 1 deletion app/models/account.rb
Expand Up @@ -37,7 +37,9 @@ def with_defaults
end

has_many :line_items
has_many :account_items
has_many :statements

has_many :account_items, :extend => CategorizedItems

after_create :create_default_buckets, :set_starting_balance

Expand Down
21 changes: 21 additions & 0 deletions app/models/account_item.rb
Expand Up @@ -10,10 +10,31 @@ class AccountItem < ActiveRecord::Base

belongs_to :event
belongs_to :account
belongs_to :statement

after_create :increment_balance
before_destroy :decrement_balance

named_scope :uncleared, lambda { |*args| AccountItem.options_for_uncleared(*args) }

def self.options_for_uncleared(*args)
raise ArgumentError, "too many arguments #{args.length} for 1" if args.length > 1

options = args.first || {}
raise ArgumentError, "expected Hash, got #{options.class}" unless options.is_a?(Hash)
options = options.dup

conditions = "statement_id IS NULL"
parameters = []

if options[:with]
conditions = "(#{conditions} OR statement_id = ?)"
parameters << options[:with]
end

{ :conditions => [conditions, *parameters], :include => options[:include] }
end

protected

def increment_balance
Expand Down
92 changes: 92 additions & 0 deletions app/models/statement.rb
@@ -0,0 +1,92 @@
class Statement < ActiveRecord::Base
belongs_to :account
has_many :account_items, :extend => CategorizedItems, :dependent => :nullify

before_create :initialize_starting_balance
after_save :associate_account_items_with_self

named_scope :pending, :conditions => { :balanced_at => nil }
named_scope :balanced, :conditions => "balanced_at IS NOT NULL"

attr_accessible :occurred_on, :ending_balance, :cleared

validates_presence_of :occurred_on, :ending_balance

def ending_balance=(amount)
if amount.is_a?(Float) || amount =~ /[.,]/
amount = (amount.to_s.tr(",", "").to_f * 100).to_i
end

super(amount)
end

def balance
ending_balance
end

def balanced?(reload=false)
unsettled_balance(reload).zero?
end

def settled_balance(reload=false)
@settled_balance = nil if reload
@settled_balance ||= account_items.to_a.sum(&:amount)
end

def unsettled_balance(reload=false)
@unsettled_balance = nil if reload
@unsettled_balance ||= starting_balance + settled_balance(reload) - ending_balance
end

def cleared=(ids)
@ids_to_clear = ids
@already_updated = false
end

protected

def initialize_starting_balance
self.starting_balance ||= account.statements.balanced.last.try(:ending_balance) || 0
end

def associate_account_items_with_self
return if @already_updated
@already_updated = true

account_items.clear

ids = connection.select_values(sanitize_sql([<<-SQL.squish, account_id, ids_to_clear]))
SELECT ai.id
FROM account_items ai
WHERE ai.account_id = ?
AND ai.id IN (?)
SQL

connection.update(sanitize_sql([<<-SQL.squish, id, ids]))
UPDATE account_items
SET statement_id = ?
WHERE id IN (?)
SQL

account_items.reset

if @ids_to_clear
if balanced?(true) && !balanced_at
update_attribute :balanced_at, Time.now.utc
elsif !balanced? && balanced_at
update_attribute :balanced_at, nil
end
end
end

private

def sanitize_sql(sql)
self.class.send(:sanitize_sql, sql)
end

def ids_to_clear
@ids_to_clear || []
end

end
13 changes: 12 additions & 1 deletion app/views/accounts/_name.html.haml
@@ -1,2 +1,13 @@
%span.actions== #{link_to_function("Rename", "Accounts.rename(#{account_path(account).to_json}, #{account.name.to_json}, #{form_authenticity_token.to_json})")} | #{link_to("Delete", account_path(account), :method => :delete, :confirm => "Are you sure you want to delete this account?")}
%span.actions
- if account.statements.pending.any?
= link_to("Resume reconciling", edit_statement_path(account.statements.pending.first))
- else
= link_to("Reconcile", new_account_statement_path(account))
|
- if account.statements.balanced.any?
= link_to("Prior statements", account_statements_path(account))
|
= link_to_function("Rename", "Accounts.rename(#{account_path(account).to_json}, #{account.name.to_json}, #{form_authenticity_token.to_json})")
|
= link_to("Delete", account_path(account), :method => :delete, :confirm => "Are you sure you want to delete this account?")
&= account.name
8 changes: 6 additions & 2 deletions app/views/events/_balance.html.haml
@@ -1,6 +1,10 @@
%tr.current_balance
%th.date= Date.today.strftime("%Y-%m-%d")
%th Current balance
- if container.is_a?(Statement)
%th.date= statement.occurred_on.strftime("%Y-%m-%d")
%th Ending balance
- else
%th.date= Date.today.strftime("%Y-%m-%d")
%th Current balance
%th.number.total.balance{:colspan => 2}= balance_cell(container, :tag => "span", :id => "balance")
%tr.spacer
%td{:colspan => 4}
8 changes: 8 additions & 0 deletions app/views/statements/_subtotal.html.haml
@@ -0,0 +1,8 @@
.subtotal
.settled
Subtotal:
%span.subtotal_dollars= format_cents(subtotal)
.remaining
Remaining:
%span.remaining_dollars{:class => statement.unsettled_balance.zero? ? "balanced" : nil}
= format_cents(statement.unsettled_balance)
9 changes: 9 additions & 0 deletions app/views/statements/_uncleared.html.haml
@@ -0,0 +1,9 @@
%tr{:id => dom_id(uncleared), :class => uncleared_row_class(uncleared)}
%td.checkbox= check_box_tag "statement[cleared][]", uncleared.id, uncleared.statement_id, :id => dom_id(uncleared, :check), :onclick => "Statements.toggleCleared(#{uncleared.id})"
%td.date{:onclick => "Statements.clickItem(#{uncleared.id})"}= uncleared.occurred_on.strftime("%Y-%m-%d")
- if uncleared.event.check_number
%td.check{:onclick => "Statements.clickItem(#{uncleared.id})"}= "#" + uncleared.event.check_number.to_s
%td.actor{:onclick => "Statements.clickItem(#{uncleared.id})"}&= uncleared.event.actor
%td.number{:onclick => "Statements.clickItem(#{uncleared.id})"}
%span{:style => "display: none;", :id => dom_id(uncleared, :amount)}= uncleared.amount
= format_cents(uncleared.amount.abs)
55 changes: 55 additions & 0 deletions app/views/statements/edit.html.haml
@@ -0,0 +1,55 @@
#data.content
%h2== Balance your statement

.statement.edit.form

- form_for(statement) do |form|

%table.general
%tr
%th.occurred_on Statement date
%th.starting_balance Starting balance
%th.ending_balance Ending balance
%tr
%td.occurred_on= form.calendar_date_select :occurred_on, :size => 10
%td#starting_balance.starting_balance.number= format_cents(statement.starting_balance)
%td.ending_balance= "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "Statements.updateBalances()")

- if uncleared.deposits.any?
%fieldset#deposits
%legend Deposits

.uncleared.deposits
%table= render :partial => "statements/uncleared", :collection => uncleared.deposits

= render :partial => "statements/subtotal", :object => statement.account_items.deposits.sum(&:amount)

- if uncleared.checks.any?
%fieldset#checks
%legend Checks

.uncleared.checks
%table= render :partial => "statements/uncleared", :collection => uncleared.checks

= render :partial => "statements/subtotal", :object => statement.account_items.checks.sum(&:amount)

- if uncleared.expenses.any?
%fieldset#expenses
%legend Other expenses

.uncleared.expenses
%table= render :partial => "statements/uncleared", :collection => uncleared.expenses

= render :partial => "statements/subtotal", :object => statement.account_items.expenses.sum(&:amount)

#balanced{:style => visible?(statement.balanced?)}
%h3 Congratulations!

%p Your records exactly match your account statement, and everything balances.

%p= form.submit "Close out this statement"

%p#actions{:style => visible?(!statement.balanced?)}
= form.submit "Save for later"
or
= link_to("abort this reconciliation", statement_path(statement), :method => :delete, :confirm => "Are you sure you want to discard this reconciliation?")
17 changes: 17 additions & 0 deletions app/views/statements/index.html.haml
@@ -0,0 +1,17 @@
#data.content
.navigation
= link_to "Dashboard", subscription_path(subscription)
= link_to h(account.name), account_path(account)

%h2 Previous Statements

- if statements.empty?
%p== You have not yet balanced this account against any statements from your financial institution. You may start by clicking #{link_to("here", new_account_statement_path(account))}, to begin balancing this account.

- else
%ul
- statements.each do |statement|
%li
= link_to(statement.occurred_on.strftime("%Y-%m-%d"), statement_path(statement))
= ":"
= format_cents(statement.ending_balance)
25 changes: 25 additions & 0 deletions app/views/statements/new.html.haml
@@ -0,0 +1,25 @@
#data.content
%h2== Let's reconcile your #{h(account.name)} account

.form

- form_for([account, statement]) do |form|

%fieldset

%p First, take a look at the account statement from your bank or other financial institution.

%p If you haven't reconciled in a while, make sure you start with the <em>oldest</em> statement and work forward.

%p
<strong>When</strong> was the statement printed?
= form.calendar_date_select :occurred_on, :size => 10

%p
<strong>What</strong> is the ending balance?
= "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "this.value = Money.format(this)")

%p
= form.submit "Go to step #2"
or
= link_to("cancel", account_path(account))
15 changes: 15 additions & 0 deletions app/views/statements/show.html.haml
@@ -0,0 +1,15 @@
#data.content
.navigation
= link_to "Dashboard", subscription_path(subscription)
= link_to h(account.name), account_path(account)
= link_to "Prior statements", account_statements_path(account)

%h2
%span.actions
= link_to("Delete", statement_path(statement), :confirm => "Are you sure you want to delete this statement?", :method => :delete)
Statement for period ending
= statement.occurred_on.strftime("%Y-%m-%d")

%table.entries
= render :partial => "events/balance", :locals => { :container => statement }
= render(statement.account_items)

0 comments on commit 120e981

Please sign in to comment.