diff --git a/TODO b/TODO
index cd50590..21bb277 100644
--- a/TODO
+++ b/TODO
@@ -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
@@ -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
diff --git a/app/concerns/categorized_items.rb b/app/concerns/categorized_items.rb
new file mode 100644
index 0000000..a1f1243
--- /dev/null
+++ b/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
diff --git a/app/controllers/statements_controller.rb b/app/controllers/statements_controller.rb
new file mode 100644
index 0000000..44925ff
--- /dev/null
+++ b/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
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2c2b0cd..d7daaaa 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -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
diff --git a/app/helpers/statements_helper.rb b/app/helpers/statements_helper.rb
new file mode 100644
index 0000000..6ca3dc2
--- /dev/null
+++ b/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
diff --git a/app/models/account.rb b/app/models/account.rb
index 32b423e..b9874f5 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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
diff --git a/app/models/account_item.rb b/app/models/account_item.rb
index 50c4cf1..a54d413 100644
--- a/app/models/account_item.rb
+++ b/app/models/account_item.rb
@@ -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
diff --git a/app/models/statement.rb b/app/models/statement.rb
new file mode 100644
index 0000000..ebd6fb9
--- /dev/null
+++ b/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
diff --git a/app/views/accounts/_name.html.haml b/app/views/accounts/_name.html.haml
index 86f97d8..4bdd209 100644
--- a/app/views/accounts/_name.html.haml
+++ b/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
diff --git a/app/views/events/_balance.html.haml b/app/views/events/_balance.html.haml
index 35bd256..e341735 100644
--- a/app/views/events/_balance.html.haml
+++ b/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}
diff --git a/app/views/statements/_subtotal.html.haml b/app/views/statements/_subtotal.html.haml
new file mode 100644
index 0000000..a103af0
--- /dev/null
+++ b/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)
diff --git a/app/views/statements/_uncleared.html.haml b/app/views/statements/_uncleared.html.haml
new file mode 100644
index 0000000..adafcc1
--- /dev/null
+++ b/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)
diff --git a/app/views/statements/edit.html.haml b/app/views/statements/edit.html.haml
new file mode 100644
index 0000000..14ead84
--- /dev/null
+++ b/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?")
diff --git a/app/views/statements/index.html.haml b/app/views/statements/index.html.haml
new file mode 100644
index 0000000..47b24c6
--- /dev/null
+++ b/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)
diff --git a/app/views/statements/new.html.haml b/app/views/statements/new.html.haml
new file mode 100644
index 0000000..00b8bf4
--- /dev/null
+++ b/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 oldest statement and work forward.
+
+ %p
+ When was the statement printed?
+ = form.calendar_date_select :occurred_on, :size => 10
+
+ %p
+ What 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))
diff --git a/app/views/statements/show.html.haml b/app/views/statements/show.html.haml
new file mode 100644
index 0000000..8c4ca9c
--- /dev/null
+++ b/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)
diff --git a/config/routes.rb b/config/routes.rb
index 216ea5a..5bd28ef 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -4,9 +4,9 @@
map.resources :subscriptions, :has_many => [:accounts, :events, :tags]
map.resources :events, :has_many => :tagged_items, :member => { :update => :post }
map.resources :buckets, :has_many => :events
- map.resources :accounts, :has_many => [:buckets, :events]
+ map.resources :accounts, :has_many => [:buckets, :events, :statements]
map.resources :tags, :has_many => :events
- map.resources :tagged_items
+ map.resources :tagged_items, :statements
map.connect "", :controller => "subscriptions", :action => "index"
end
diff --git a/db/migrate/20090421221109_add_statements.rb b/db/migrate/20090421221109_add_statements.rb
new file mode 100644
index 0000000..2dc124d
--- /dev/null
+++ b/db/migrate/20090421221109_add_statements.rb
@@ -0,0 +1,24 @@
+class AddStatements < ActiveRecord::Migration
+ def self.up
+ create_table :statements do |t|
+ t.integer :account_id, :null => false
+ t.date :occurred_on, :null => false
+ t.integer :starting_balance
+ t.integer :ending_balance
+ t.datetime :balanced_at
+ t.timestamps
+ end
+
+ add_index :statements, %w(account_id occurred_on)
+
+ add_column :account_items, :statement_id, :integer
+ add_index :account_items, %w(statement_id occurred_on)
+ end
+
+ def self.down
+ drop_table :statements
+
+ remove_index :account_items, %w(statement_id occurred_on)
+ remove_column :account_items, :statement_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f49ca92..c6abdca 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -9,17 +9,19 @@
#
# It's strongly recommended to check this file into your version control system.
-ActiveRecord::Schema.define(:version => 20090404154634) do
+ActiveRecord::Schema.define(:version => 20090421221109) do
create_table "account_items", :force => true do |t|
- t.integer "event_id", :null => false
- t.integer "account_id", :null => false
- t.integer "amount", :null => false
- t.date "occurred_on", :null => false
+ t.integer "event_id", :null => false
+ t.integer "account_id", :null => false
+ t.integer "amount", :null => false
+ t.date "occurred_on", :null => false
+ t.integer "statement_id"
end
add_index "account_items", ["account_id", "occurred_on"], :name => "index_account_items_on_account_id_and_occurred_on"
add_index "account_items", ["event_id"], :name => "index_account_items_on_event_id"
+ add_index "account_items", ["statement_id", "occurred_on"], :name => "index_account_items_on_statement_id_and_occurred_on"
create_table "accounts", :force => true do |t|
t.integer "subscription_id", :null => false
@@ -75,6 +77,18 @@
add_index "line_items", ["bucket_id", "occurred_on"], :name => "index_line_items_on_bucket_id_and_occurred_on"
add_index "line_items", ["event_id"], :name => "index_line_items_on_event_id"
+ create_table "statements", :force => true do |t|
+ t.integer "account_id", :null => false
+ t.date "occurred_on", :null => false
+ t.integer "starting_balance"
+ t.integer "ending_balance"
+ t.datetime "balanced_at"
+ t.datetime "created_at"
+ t.datetime "updated_at"
+ end
+
+ add_index "statements", ["account_id", "occurred_on"], :name => "index_statements_on_account_id_and_occurred_on"
+
create_table "subscriptions", :force => true do |t|
t.integer "owner_id", :null => false
end
diff --git a/public/javascripts/money.js b/public/javascripts/money.js
index bc7bf2a..9679eac 100644
--- a/public/javascripts/money.js
+++ b/public/javascripts/money.js
@@ -1,21 +1,22 @@
var Money = {
- dollars: function(cents) {
- return (Math.abs(cents) / 100).toFixed(2);
+ dollars: function(cents, keepNegative) {
+ cents = keepNegative ? cents : Math.abs(cents);
+ return (cents / 100).toFixed(2);
},
parse: function(field, keepNegative) {
- return Money.parseValue($F(field));
+ return Money.parseValue($F(field), keepNegative);
},
/* don't want to use parseFloat, because we can be subject to
* floating point round-off */
parseValue: function(string, keepNegative) {
var value = string.gsub(/[^-+\d.]/, "");
- var match = value.match(/^([-+]?)(\d*)(?:\.(\d+))?$/);
+ var match = value.match(/^([-+]?)(\d*)(?:\.(\d*))?$/);
if(!match) return 0;
- var sign = (match[1] == "-" && keepNegative) ? -1 : 1;
+ var sign = ((match[1] == "-") && keepNegative) ? -1 : 1;
if(match[2].length > 0)
var dollars = parseInt(match[2]);
else
@@ -31,5 +32,45 @@ var Money = {
}
return sign * (dollars * 100 + cents);
+ },
+
+ format: function(field) {
+ return Money.formatValue(Money.parse(field, true));
+ },
+
+ formatValue: function(cents) {
+ var sign = cents < 0 ? -1 : 1;
+ var source = String(Math.abs(cents));
+ var result;
+
+ if(source.length > 2) {
+ result = "." + source.slice(-2);
+ source = source.slice(0,-2);
+ } else if(source.length == 2) {
+ result = "." + source;
+ source = "";
+ } else if(source.length == 1) {
+ result = ".0" + source;
+ source = "";
+ } else {
+ result = ".00";
+ }
+
+ while(source.length > 3) {
+ result = "," + source.slice(-3) + result;
+ source = source.slice(0,-3);
+ }
+
+ if(source.length > 0) {
+ result = source + result;
+ } else if(result[0] == ".") {
+ result = "0" + result;
+ }
+
+ if(sign < 0) {
+ result = "-" + result;
+ }
+
+ return result;
}
}
diff --git a/public/javascripts/statements.js b/public/javascripts/statements.js
new file mode 100644
index 0000000..e9d4af2
--- /dev/null
+++ b/public/javascripts/statements.js
@@ -0,0 +1,71 @@
+var Statements = {
+ clickItem: function(id) {
+ $('check_account_item_' + id).click();
+ },
+
+ toggleCleared: function(id) {
+ var checked = $('check_account_item_' + id).checked;
+ var amount = parseInt($('amount_account_item_' + id).innerHTML);
+ var subtotalField = $('account_item_' + id).up('fieldset').down('.subtotal_dollars');
+ var subtotal = Money.parseValue(subtotalField.innerHTML, true);
+
+ if(checked) {
+ $('account_item_' + id).addClassName('cleared');
+ subtotalField.innerHTML = "$" + Money.formatValue(subtotal + amount);
+ } else {
+ $('account_item_' + id).removeClassName('cleared');
+ subtotalField.innerHTML = "$" + Money.formatValue(subtotal - amount);
+ }
+
+ Statements.updateBalances();
+ },
+
+ startingBalance: function() {
+ if(!Statements.cachedBalance)
+ Statements.cachedBalance = Money.parseValue($('starting_balance').innerHTML, true);
+ return Statements.cachedBalance;
+ },
+
+ endingBalance: function() {
+ return Money.parse($('statement_ending_balance'), true);
+ },
+
+ settled: function() {
+ return $$('.subtotal_dollars').inject(0, function(sum, span) {
+ return sum + Money.parseValue(span.innerHTML, true);
+ });
+ },
+
+ remaining: function() {
+ return Statements.startingBalance() + Statements.settled() - Statements.endingBalance();
+ },
+
+ updateBalances: function() {
+ var ending = $('statement_ending_balance');
+ ending.value = Money.format(ending);
+
+ var remaining = Statements.remaining();
+ var remainingText = Money.formatValue(remaining);
+
+ ['deposits', 'checks', 'expenses'].each(function(section) {
+ if($(section)) {
+ var span = $$("#" + section + " .remaining_dollars").first();
+
+ if(remaining == 0)
+ span.addClassName("balanced");
+ else
+ span.removeClassName("balanced");
+
+ span.innerHTML = "$" + remainingText;
+ }
+ })
+
+ if(remaining == 0) {
+ $('balanced').show();
+ $('actions').hide();
+ } else {
+ $('balanced').hide();
+ $('actions').show();
+ }
+ }
+}
diff --git a/public/stylesheets/money.css b/public/stylesheets/money.css
index dfe17bc..7cf14f5 100644
--- a/public/stylesheets/money.css
+++ b/public/stylesheets/money.css
@@ -617,3 +617,138 @@ body.login .notice {
#blankslate p.create a {
text-decoration: underline;
}
+
+/* ------------------------------------------------------------------ *
+ * ACCOUNT RECONCILIATION
+ * ------------------------------------------------------------------ */
+
+.statement table.general {
+ margin: 0;
+ margin-bottom: 1em;
+}
+
+.statement table.general th {
+ font-size: 80%;
+ color: #777;
+ white-space: nowrap;
+}
+
+.statement table.general td.occurred_on,
+.statement table.general th.occurred_on {
+ width: 40%;
+}
+
+.statement table.general td.starting_balance,
+.statement table.general th.starting_balance {
+ width: 20%;
+ text-align: right;
+}
+
+.statement table.general td.ending_balance,
+.statement table.general th.ending_balance {
+ width: 40%;
+ text-align: right;
+}
+
+.statement fieldset {
+ padding: 0;
+}
+
+.statement .subtotal {
+ border-top: 1px solid #ccc;
+ font-size: 80%;
+}
+
+.statement .settled {
+ float: right;
+ padding: 0.5em;
+}
+
+.statement .remaining {
+ float: left;
+ padding: 0.5em;
+}
+
+.statement .remaining .balanced {
+ color: #070;
+ font-weight: bold;
+}
+
+.uncleared {
+ margin: 0.5em;
+}
+
+.uncleared.expenses {
+ height: 12em;
+ overflow: auto;
+}
+
+.uncleared.checks {
+ height: 12em;
+ overflow: auto;
+}
+
+.uncleared.deposits {
+ height: 5em;
+ overflow: auto;
+}
+
+.uncleared table {
+ margin: 0;
+}
+
+.uncleared tr {
+ height: 1.4em;
+ cursor: pointer;
+}
+
+.uncleared td {
+ font-size: 80%;
+ vertical-align: bottom;
+}
+
+.uncleared td.checkbox {
+ width: 1px;
+ padding-right: 1em;
+}
+
+.uncleared td.date {
+ color: black;
+ padding-right: 1em;
+}
+
+.uncleared td.check {
+ width: 1px;
+ padding-right: 1em;
+}
+
+.uncleared tr.cleared td {
+ text-decoration: line-through;
+ color: #777;
+}
+
+.uncleared td {
+ padding: 0.2em;
+}
+
+.uncleared input {
+ margin: 0;
+}
+
+#balanced {
+ border: 1px solid #aca;
+ background: #cfc;
+ margin: 1em 0;
+}
+
+#balanced h3 {
+ margin: 0;
+ border-bottom: 1px solid #aca;
+ background: #070;
+ color: white;
+ padding: 0.5em;
+}
+
+#balanced p {
+ margin: 1em;
+}
diff --git a/test/fixtures/account_items.yml b/test/fixtures/account_items.yml
index 242df43..ad53111 100644
--- a/test/fixtures/account_items.yml
+++ b/test/fixtures/account_items.yml
@@ -7,6 +7,7 @@ john_checking_starting_balance:
account: john_checking
amount: 100000
occurred_on: <%= 60.days.ago.to_date.to_s(:db) %>
+ statement: john
john_lunch_mastercard:
event: john_lunch
@@ -25,6 +26,7 @@ john_bill_pay_checking:
account: john_checking
amount: -775
occurred_on: <%= 58.days.ago.utc.to_s(:db) %>
+ statement: john
john_bill_pay_mastercard:
event: john_bill_pay
diff --git a/test/fixtures/statements.yml b/test/fixtures/statements.yml
new file mode 100644
index 0000000..ff4d212
--- /dev/null
+++ b/test/fixtures/statements.yml
@@ -0,0 +1,34 @@
+# --------------------------------------------------------------
+# john
+# --------------------------------------------------------------
+
+john:
+ account: john_checking
+ occurred_on: <%= 2.weeks.ago.to_date.to_s(:db) %>
+ starting_balance: 0
+ ending_balance: 99225
+ balanced_at: <%= 1.week.ago.to_s(:db) %>
+ created_at: <%= 1.week.ago.to_s(:db) %>
+ updated_at: <%= 1.week.ago.to_s(:db) %>
+
+john_pending:
+ account: john_checking
+ occurred_on: <%= Date.today %>
+ starting_balance: 99225
+ ending_balance: 125392
+ balanced_at: ~
+ created_at: <%= Time.now.to_s(:db) %>
+ updated_at: <%= Time.now.to_s(:db) %>
+
+# --------------------------------------------------------------
+# tim
+# --------------------------------------------------------------
+
+tim:
+ account: tim_checking
+ occurred_on: <%= 1.week.ago.to_date.to_s(:db) %>
+ starting_balance: 0
+ ending_balance: 123456
+ balanced_at: ~
+ created_at: <%= 1.week.ago.to_s(:db) %>
+ updated_at: <%= 1.week.ago.to_s(:db) %>
diff --git a/test/functional/statements_controller_test.rb b/test/functional/statements_controller_test.rb
new file mode 100644
index 0000000..2161b1e
--- /dev/null
+++ b/test/functional/statements_controller_test.rb
@@ -0,0 +1,102 @@
+require 'test_helper'
+
+class StatementsControllerTest < ActionController::TestCase
+ setup :login_default_user
+
+ test "index for inaccessible account should 404" do
+ get :index, :account_id => accounts(:tim_checking).id
+ assert_response :missing
+ end
+
+ test "index should list only balanced statements" do
+ get :index, :account_id => accounts(:john_checking).id
+ assert_response :success
+ assert_template "statements/index"
+ assert_equal [statements(:john)], assigns(:statements)
+ end
+
+ test "new for inaccessible account should 404" do
+ get :new, :account_id => accounts(:tim_checking).id
+ assert_response :missing
+ end
+
+ test "new should build template record and render" do
+ get :new, :account_id => accounts(:john_checking).id
+ assert_response :success
+ assert_template "statements/new"
+ assert assigns(:statement).new_record?
+ end
+
+ test "create for inaccessible account should 404" do
+ assert_no_difference "Statement.count" do
+ post :create, :account_id => accounts(:tim_checking).id,
+ :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 }
+ assert_response :missing
+ end
+ end
+
+ test "create should create new record and redirect to edit" do
+ assert_difference "accounts(:john_checking, :reload).statements.size" do
+ post :create, :account_id => accounts(:john_checking).id,
+ :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 }
+ assert_redirected_to edit_statement_url(assigns(:statement))
+ end
+ end
+
+ test "show for inaccessible statement should 404" do
+ get :show, :id => statements(:tim).id
+ assert_response :missing
+ end
+
+ test "show should load statement record and render" do
+ get :show, :id => statements(:john).id
+ assert_response :success
+ assert_template "statements/show"
+ assert_equal statements(:john), assigns(:statement)
+ end
+
+ test "edit for inaccessible statement should 404" do
+ get :edit, :id => statements(:tim).id
+ assert_response :missing
+ end
+
+ test "edit should load statement record and render" do
+ get :edit, :id => statements(:john).id
+ assert_response :success
+ assert_template "statements/edit"
+ assert_equal statements(:john), assigns(:statement)
+ end
+
+ test "update for inaccessible statement should 404" do
+ put :update, :id => statements(:tim).id,
+ :statement => { :occurred_on => statements(:tim).occurred_on,
+ :ending_balance => statements(:tim).ending_balance,
+ :cleared => [account_items(:tim_checking_starting_balance).id] }
+ assert_response :missing
+ assert statements(:tim, :reload).account_items.empty?
+ end
+
+ test "update should load statement and update statement and redirect" do
+ assert statements(:john_pending).account_items.empty?
+ put :update, :id => statements(:john_pending).id,
+ :statement => { :occurred_on => statements(:john_pending).occurred_on,
+ :ending_balance => statements(:john_pending).ending_balance,
+ :cleared => [account_items(:john_lunch_again_checking).id] }
+ assert_redirected_to account_url(statements(:john_pending).account)
+ assert_equal [account_items(:john_lunch_again_checking)],
+ statements(:john_pending, :reload).account_items
+ end
+
+ test "destroy for inaccessible statement should 404" do
+ assert_no_difference "Statement.count" do
+ delete :destroy, :id => statements(:tim).id
+ assert_response :missing
+ end
+ end
+
+ test "destroy should load and destroy statement and redirect" do
+ delete :destroy, :id => statements(:john).id
+ assert_redirected_to account_url(statements(:john).account)
+ assert !Statement.exists?(statements(:john).id)
+ end
+end
diff --git a/test/unit/statement_test.rb b/test/unit/statement_test.rb
new file mode 100644
index 0000000..a4e69f7
--- /dev/null
+++ b/test/unit/statement_test.rb
@@ -0,0 +1,80 @@
+require 'test_helper'
+
+class StatementTest < ActiveSupport::TestCase
+ test "creation with ending balance as dollars should be translated to cents" do
+ statement = accounts(:john_checking).statements.create(:occurred_on => Date.today,
+ :ending_balance => "1,234.56")
+ assert_equal 1_234_56, statement.ending_balance
+ end
+
+ test "creation with cleared ids should set statement id for given account items" do
+ items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
+
+ statement = accounts(:john_checking).statements.create(
+ :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => items)
+
+ assert_equal items, statement.account_items.map(&:id)
+ end
+
+ test "creation with cleared ids should filter out items for different accounts" do
+ items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
+ bad_items = items + [account_items(:john_lunch_mastercard).id]
+
+ statement = accounts(:john_checking).statements.create(
+ :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => bad_items)
+
+ assert_equal items, statement.account_items.map(&:id)
+ end
+
+ test "balanced_at should not be set when no items have been given" do
+ statement = accounts(:john_checking).statements.create(:occurred_on => Date.today,
+ :ending_balance => 1_234_56)
+ assert_nil statement.balanced_at
+ end
+
+ test "balanced_at should be set automatically when items all balance" do
+ statements(:john).destroy # get this one out of the way
+
+ items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
+
+ statement = accounts(:john_checking).statements.create(
+ :occurred_on => Date.today, :ending_balance => 992_25, :cleared => items)
+
+ assert_not_nil statement.balanced_at
+ assert (Time.now - statement.balanced_at) < 1
+ end
+
+ test "balanced_at should be cleared automatically when items do not balance" do
+ items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id }
+
+ statement = accounts(:john_checking).statements.create(:occurred_on => Date.today,
+ :ending_balance => 1_234_56)
+ statement.balanced_at = Time.now.utc
+ statement.save
+
+ assert_not_nil statement.reload.balanced_at
+
+ statement.update_attributes :cleared => items
+ assert_nil statement.reload.balanced_at
+ end
+
+ test "deleting statement should nullify association with account items" do
+ assert statements(:john).account_items.any?
+ assert_equal statements(:john), account_items(:john_checking_starting_balance).statement
+
+ assert_difference "Statement.count", -1 do
+ assert_no_difference "AccountItem.count" do
+ statements(:john).destroy
+ end
+ end
+
+ assert_nil account_items(:john_checking_starting_balance, :reload).statement
+ end
+
+ test "balanced should be true when unsettled balance is zero" do
+ assert statements(:john).balanced?
+ statements(:john).update_attributes :ending_balance => 1234_56,
+ :cleared => statements(:john).account_items.map(&:id)
+ assert !statements(:john).balanced?(true)
+ end
+end