diff --git a/examples/entity_sample.transactions b/examples/entity_sample.transactions new file mode 100644 index 0000000..f3418b8 --- /dev/null +++ b/examples/entity_sample.transactions @@ -0,0 +1,18 @@ +2013-01-01 I began the year with $1000 in my cheque account. + Assets:Bankwest:Cheque $1,000 + Equity:OpeningBalances $1,000 + +2013-01-05 I bought some groceries and paid using the cheque account. + Jacob/Expenses:Food:Groceries $98.53 + Jacob/Assets:Bankwest:Cheque -$98.53 + +2013-01-10 I bought some petrol, and paid using a credit card. + John/Expenses:Motor:Fuel $58.01 + John/Liabilities:Bankwest:Visa $58.01 + +2013-01-15 I paid my electricity bill. + Expenses:Electricity $280.42 + Assets:Bankwest:Cheque -$280.42 + +# I checked my bank statement on the 1st of Feb, and this is what it said. +VERIFY-BALANCE 2013-02-01 Assets:Bankwest:Cheque 719.58 diff --git a/examples/unbalanced_entity_sample.transactions b/examples/unbalanced_entity_sample.transactions new file mode 100644 index 0000000..c54122f --- /dev/null +++ b/examples/unbalanced_entity_sample.transactions @@ -0,0 +1,18 @@ +2013-01-01 I began the year with $1000 in my cheque account. + John/Assets:Bankwest:Cheque $1,000 + John/Equity:OpeningBalances $1,000 + +2013-01-05 I bought some groceries and paid using the cheque account. + Jacob/Expenses:Food:Groceries $98.53 + John/Assets:Bankwest:Cheque -$98.53 + +2013-01-10 I bought some petrol, and paid using a credit card. + Jacob/Expenses:Motor:Fuel $58.01 + Liabilities:Bankwest:Visa $58.01 + +2013-01-15 I paid my electricity bill. + Expenses:Electricity $280.42 + Assets:Bankwest:Cheque -$280.42 + +# I checked my bank statement on the 1st of Feb, and this is what it said. +VERIFY-BALANCE 2013-02-01 Assets:Bankwest:Cheque 621.05 diff --git a/src/fsharp/Ledger/Calculations.fs b/src/fsharp/Ledger/Calculations.fs index ae840e5..c69d972 100644 --- a/src/fsharp/Ledger/Calculations.fs +++ b/src/fsharp/Ledger/Calculations.fs @@ -31,6 +31,10 @@ type DateOrderCheck = | OK | Problem of previous: Transaction * next: Transaction +type EntityVerification = + | EntityVerificationPassed + | UnbalancedEntities of AccountEntity list + /// Check transactions are in date order. Give two problem transactions if not. let checkDateOrder (transactions : Transaction list) = let rec helper (previous : Transaction) (transactions : Transaction list) = @@ -45,6 +49,51 @@ let checkDateOrder (transactions : Transaction list) = | [] -> DateOrderCheck.OK | (t :: tail) -> (helper t tail) +/// This function will check a list of postings for an uneven number of the entities seen within +/// for example: +/// +/// Jacob/Expenses:Motor:Fuel $58.01 +/// Liabilities:Bankwest:Visa $58.01 +/// +/// Will result in the following function result: +/// UnbalancedEntities [(Entity Jacob,true); (Default,true)] +/// +/// Assuming that they do balanced, like so: +/// Expenses:Motor:Fuel $58.01 +/// Liabilities:Bankwest:Visa $58.01 +/// +/// then the function will return EntityVerificationPassed. Perhaps there is a better way to check for the # +/// such as keeping a running total and "modding" (%) by 2 to see if there is an even number of entities +/// I'm keeping it like this because I think the concept of flipping "bits" is neat, although overcomplicated +let verifyEntitiesInPostings (postings:Posting list) = + let errors = postings + |> List.fold (fun (bins:(AccountEntity*bool) list) (elem:Posting) -> + let entity = elem.account.Entity + let bin = bins |> List.tryFind (fst >> (=) entity) + match bin with + | Some b -> bins |> List.map (fun (entity,b) -> (entity,not b)) + | None -> (entity, true) :: bins) [] + |> List.filter (snd >> (=) true) + |> List.map (fun (entity,b) -> entity) + match errors with + | [] -> EntityVerificationPassed + | _ -> UnbalancedEntities errors + +/// Performs verifyEntitiesInPostings of each transaction's postings in the supplied list +/// It will then map the results into a tuple of (Transaction*EntityVerification List) +/// This allows us to refer to the offending transaction in errors, as well as checking each +/// transaction individually rather than checking the ENTIRE file for unbalanced entities +/// this allows for a faster tracking of entity issues +let verifyEntitiesInTransactions (transactions:Transaction list) = + transactions + |> List.map (fun transaction -> + (transaction, match verifyEntitiesInPostings transaction.postings with + | EntityVerificationPassed -> [] + | UnbalancedEntities entities -> entities)) + |> List.filter (snd >> (<>) []) + // F# never ceases to amaze. This bit basically says "filter all items where the second item in a tuple is not an empty list". + // the equivalent is (fun x -> snd x <> []) would achieve the same result + /// Is transaction unbalanced? let balance (t:Transaction) = let signedAmount (p: Posting) = @@ -66,7 +115,7 @@ let filter (transactions : Transaction list) (first : Date option) (last : Date // Is a a sub-account of b? let isSubAccountOf (a: InputNameAccount) (b: InputNameAccount) = - startsWith (canonicalAccountName a) (canonicalAccountName b) + startsWith (canonicalAccountName a) (canonicalAccountName b) && a.Entity=b.Entity /// XXX: affectedBy(Posting/Transaction) should be a method on AccountName, which should be a class. Do we even need these at all? let postingAffects (p:Posting) (a: InputNameAccount) = diff --git a/src/fsharp/Ledger/ExcelOutput.fs b/src/fsharp/Ledger/ExcelOutput.fs index 1a43f18..5241204 100644 --- a/src/fsharp/Ledger/ExcelOutput.fs +++ b/src/fsharp/Ledger/ExcelOutput.fs @@ -4,6 +4,7 @@ open InputTypes open TextOutput open InternalTypes open OfficeOpenXml +open Misc type Destination = ExcelPackage option @@ -403,10 +404,7 @@ type Excel = for c in (txnNumCol-1)..(txnNumCol+2) do worksheet.Column(c).AutoFit(0.0) - static member writeLine((line: ReportBalances.Line), - (ws : ExcelWorksheet), - (indent: int), - (nextRow: int)) = + static member writeLine((line: ReportBalances.Line), (ws : ExcelWorksheet), (indent: int), (nextRow: int)) = (Excel.setValue (ws.Cells.[nextRow, 1], line.Balance)) Excel.setValue (ws.Cells.[nextRow, 2+indent], line.Account.AsString) if indent <> 0 then @@ -416,10 +414,7 @@ type Excel = ws.Row(nextRow).Collapsed <- true rowAfterChildren - static member writeLines((lines : ReportBalances.Line list), - (ws : ExcelWorksheet), - (indent: int), - (nextRow: int)) = + static member writeLines((lines : ReportBalances.Line list), (ws : ExcelWorksheet), (indent: int), (nextRow: int)) = match lines with | [] -> nextRow | first::rest -> Excel.writeLines(rest, ws, indent, Excel.writeLine(first, ws, indent, nextRow)) @@ -431,59 +426,56 @@ type Excel = let worksheet = package.Workbook.Worksheets.Add("Balances") (setHeader worksheet.Cells.[1, 1] "Balance") (setHeader worksheet.Cells.[1, 2] "Account") - Excel.writeLines(report.lines, worksheet, 0, 2) |> ignore + Excel.writeLines(report.Lines, worksheet, 0, 2) |> ignore worksheet.View.FreezePanes(2, 1) worksheet.OutLineSummaryBelow <- false - static member writeLine((line: ReportChartOfAccounts.Line), - (ws : ExcelWorksheet), - (indent: int), - (nextRow: int)) = - Excel.setValue (ws.Cells.[nextRow, 1+indent], line.Account.AsString) - if indent <> 0 then + static member writeLine((line: ReportChartOfAccounts.Line), (ws : ExcelWorksheet), (indent: int), (nextRow: int)) = + Excel.setValue (ws.Cells.[nextRow, 1+indent], line.Account.Name) + if indent <> 1 then ws.Row(nextRow).OutlineLevel <- (indent) // Deliberately avoid collapsing hierarchy. If we're looking, we probably want to // emphasise details, and it's easy to manually hide them if that's what is wanted. Excel.writeLines (line.SubAccounts, ws, indent+1, nextRow+1) - static member writeLines((lines : ReportChartOfAccounts.Line list), - (ws : ExcelWorksheet), - (indent: int), - (nextRow: int)) = - match lines with - | [] -> nextRow - | first::rest -> Excel.writeLines(rest, ws, indent, Excel.writeLine(first, ws, indent, nextRow)) + static member writeLines((lines : ReportChartOfAccounts.Line list), (ws : ExcelWorksheet), (indent: int), (nextRow: int)) = + match lines with + | [] -> nextRow + | first::rest -> Excel.writeLines(rest, ws, indent, Excel.writeLine(first, ws, indent, nextRow)) static member write((report : ReportChartOfAccounts.Report), (destination : Destination)) = - match destination with - | None -> () - | Some package -> - let worksheet = package.Workbook.Worksheets.Add("Chart Of Accounts") - (setHeader worksheet.Cells.[1, 1] "Account") - Excel.writeLines(report.Lines, worksheet, 0, 2) |> ignore - worksheet.View.FreezePanes(2, 1) - worksheet.OutLineSummaryBelow <- false - - static member writeLine((line: ReportTransactionList.Line), - (ws : ExcelWorksheet), - (nextRow: int)) = - + match destination with + | None -> () + | Some package -> + let worksheet = package.Workbook.Worksheets.Add("Chart Of Accounts") + (setHeader worksheet.Cells.[1, 1] "Account") + report.Lines + |> List.map (fun line -> (line.Account.Entity, line)) + |> List.collapse + |> List.fold (fun nextRow (entity,accounts) -> + Excel.setValue(worksheet.Cells.[nextRow,1], entity.AsString) + Excel.writeLines(accounts, worksheet, 1, nextRow+1)) 2 + |> ignore + worksheet.View.FreezePanes(2, 1) + worksheet.OutLineSummaryBelow <- false + + static member writeLine((line: ReportTransactionList.Line), (ws : ExcelWorksheet), (nextRow: int)) = let txnCell = ws.Cells.[nextRow, 1] let dateCell = ws.Cells.[nextRow, 2] let descCell = ws.Cells.[nextRow, 3] - + Excel.setValue (txnCell, (sprintf "txn:%d" line.transaction.id)) txnCell.Style.Border.Top.Style <- OfficeOpenXml.Style.ExcelBorderStyle.Thin - + Excel.setValue (dateCell, line.transaction.date) dateCell.Style.Font.Bold <- true dateCell.Style.Border.Top.Style <- OfficeOpenXml.Style.ExcelBorderStyle.Thin - + Excel.setValue (descCell, line.transaction.description) descCell.Style.Font.Bold <- true descCell.Style.Font.Italic <- true descCell.Style.Border.Top.Style <- OfficeOpenXml.Style.ExcelBorderStyle.Thin - + line.transaction.postings |> List.fold (fun row p -> Excel.setValue (ws.Cells.[row, 2], p.amount) @@ -491,31 +483,29 @@ type Excel = row+1) (nextRow+1) - static member writeLines((lines : ReportTransactionList.Line list), - (ws : ExcelWorksheet), - (nextRow: int)) = - match lines with - | [] -> nextRow - | first::rest -> Excel.writeLines(rest, ws, Excel.writeLine(first, ws, nextRow)) + static member writeLines((lines : ReportTransactionList.Line list), (ws : ExcelWorksheet), (nextRow: int)) = + match lines with + | [] -> nextRow + | first::rest -> Excel.writeLines(rest, ws, Excel.writeLine(first, ws, nextRow)) static member write((report : ReportTransactionList.Report), (destination : Destination)) = - match destination with - | None -> () - | Some package -> - let worksheet = package.Workbook.Worksheets.Add("Transactions") - - match report.first with - | Some date -> (setHeader worksheet.Cells.[1, 1] "From:") - (setHeader worksheet.Cells.[1, 2] date) - | None -> () - match report.last with - | Some date -> (setHeader worksheet.Cells.[2, 1] "To:") - (setHeader worksheet.Cells.[2, 2] date) - | None -> () - (setHeader worksheet.Cells.[3, 1] "Transaction#") - (setHeader worksheet.Cells.[3, 2] "Date/Amount") - (setHeader worksheet.Cells.[3, 3] "Description/Account") - worksheet.View.FreezePanes(4, 1) - Excel.writeLines(report.lines, worksheet, 4) |> ignore - for c in 1..3 do - worksheet.Column(c).AutoFit(0.0) + match destination with + | None -> () + | Some package -> + let worksheet = package.Workbook.Worksheets.Add("Transactions") + + match report.first with + | Some date -> (setHeader worksheet.Cells.[1, 1] "From:") + (setHeader worksheet.Cells.[1, 2] date) + | None -> () + match report.last with + | Some date -> (setHeader worksheet.Cells.[2, 1] "To:") + (setHeader worksheet.Cells.[2, 2] date) + | None -> () + (setHeader worksheet.Cells.[3, 1] "Transaction#") + (setHeader worksheet.Cells.[3, 2] "Date/Amount") + (setHeader worksheet.Cells.[3, 3] "Description/Account") + worksheet.View.FreezePanes(4, 1) + Excel.writeLines(report.lines, worksheet, 4) |> ignore + for c in 1..3 do + worksheet.Column(c).AutoFit(0.0) diff --git a/src/fsharp/Ledger/InputTypes.fs b/src/fsharp/Ledger/InputTypes.fs index 32af2c6..b698db5 100644 --- a/src/fsharp/Ledger/InputTypes.fs +++ b/src/fsharp/Ledger/InputTypes.fs @@ -9,13 +9,37 @@ type Date = string type Description = string -type InputNameAccount = InputName of string +type AccountName = string + +type AccountEntity = + | Default + | Entity of string + with + member this.AsString = + match this with + | Default -> "Default" + | Entity e -> e + +type InputNameAccount = InputName of (AccountEntity * AccountName) with member this.AsString = - match this with (InputName x) -> x + match this with (InputName (entity,name)) -> entity.AsString + "/" + name + member this.Name = + match this with (InputName (entity,name)) -> name + member this.Entity = + match this with (InputName (entity,name)) -> entity exception BadAccountNameException of name: InputNameAccount * problem: string +exception EntityMismatch of acc1:InputNameAccount * acc2:InputNameAccount + with + member this.ToString = + "Entity Mismatch: " + this.acc1.Entity.AsString + " <> " + this.acc2.Entity.AsString + "\r\n" + + "Additional Information: " + this.acc1.AsString + " <> " + this.acc2.AsString + "\r\n" + + "Stacktrace: " + this.StackTrace + +exception BadEntityNameException of name:InputNameAccount * problem: string + type Amount = /// AUD amounts are stored as cents, and converted to dollars on input/output. | AUD of int diff --git a/src/fsharp/Ledger/InternalTypes.fs b/src/fsharp/Ledger/InternalTypes.fs index 184c2e3..469064c 100644 --- a/src/fsharp/Ledger/InternalTypes.fs +++ b/src/fsharp/Ledger/InternalTypes.fs @@ -23,11 +23,11 @@ let sign (accountType: AccountType) = | Equity -> -1 /// Which of the five basic account types is this? -let accountType (InputName name) = +let accountType (InputName (entity,name)) = let components = name.ToUpper().Split(':') |> Array.toList let root = match components with | root::tail -> root - | _ -> raise (BadAccountNameException((InputName name), "Empty name")) + | _ -> raise (BadAccountNameException((InputName (entity,name)), "Empty name")) match root with | "ASSET" -> Asset | "ASSETS" -> Asset @@ -39,58 +39,66 @@ let accountType (InputName name) = | "EXPENSE" -> Expense | "EXPENSES" -> Expense | "EQUITY" -> Equity - | _ -> raise (BadAccountNameException((InputName name), "Unable to determine account type")) + | _ -> raise (BadAccountNameException((InputName (entity,name)), "Unable to determine account type")) /// Do we have a valid account name? let validAccountName (a:InputNameAccount) = try match (accountType a) with _ -> true with BadAccountNameException(name, problem) -> false - -type CanonicalNameComponent = Canonical of string -type InputNameComponent = Input of string + +type CanonicalNameComponent = + | Canonical of (AccountEntity * string) with + member this.Entity = + match this with + | Canonical (entity,canonical) -> entity + member this.CanonicalName = + match this with + | Canonical (entity, canonical) -> canonical member this.AsInputName = - match this with (Input str) -> (InputName str) + match this with + | Canonical (entity, canonical) -> InputName(entity, canonical) type AccountNameComponent = { Canonical: CanonicalNameComponent; - Input: InputNameComponent} + Input: InputNameAccount} type InternalNameAccount = AccountNameComponent list let toInputName (name: InternalNameAccount) : InputNameAccount = + let entity = name.[0].Input.Entity //XXX: There has to be a better way, perhaps we could even do away with this function completely?? let rec helper (name: InternalNameAccount) = match name with | [] -> "" | only::[] -> match (only.Input) with - (Input only) -> only + (InputName (entity,name)) -> name | first::rest -> match (first.Input) with - (Input first) -> first + ":"+(helper rest) - (InputName (helper name)) + (InputName (entity,name)) -> name + ":" + (helper rest) + (InputName (entity, (helper name))) let canonicalRootName name = let accountType = accountType name (Canonical (match accountType with - | Asset -> "ASSETS" - | Liability -> "LIABILITY" - | Income -> "INCOME" - | Expense -> "EXPENSE" - | Equity -> "EQUITY")) + | Asset -> (name.Entity, "ASSETS") + | Liability -> (name.Entity, "LIABILITY") + | Income -> (name.Entity, "INCOME") + | Expense -> (name.Entity, "EXPENSE") + | Equity -> (name.Entity, "EQUITY"))) /// Break AccountName into ordered list of components. /// For each level of the account, we produce canonical & input components. /// Checkout out unit test for an example of what this does. -let splitAccountName (InputName name) : InternalNameAccount = +let splitAccountName (InputName (entity,name)) : InternalNameAccount = let components = name.Split(':') |> Array.toList let rec helper (components: string list) = match components with | [] -> [] - | first::rest -> {Canonical = (Canonical (first.ToUpper())); Input = (Input first)} :: (helper rest) + | first::rest -> {Canonical = (Canonical (entity, first.ToUpper())); Input = (InputName (entity,first))} :: (helper rest) match components with - | root::rest -> {Canonical = (canonicalRootName (InputName name)); Input = (Input root)} :: (helper rest) - | [] -> raise (BadAccountNameException((InputName name), "Empty name")) + | root::rest -> {Canonical = (canonicalRootName (InputName (entity,name))); Input = (InputName (entity,root))} :: (helper rest) + | [] -> raise (BadAccountNameException((InputName (entity,name), "Empty name"))) type InputNameAccount @@ -102,8 +110,10 @@ type InputNameAccount | last :: _ -> last | [] -> raise (BadAccountNameException(this, "Empty account name")) -let joinInputNames (InputName parentName) (Input childName) = - (InputName (parentName + ":" + childName)) +let joinInputNames (InputName (parentEntity,parentName)) (InputName (childEntity, childName)) = + if parentEntity <> childEntity then + raise (EntityMismatch ((InputName (parentEntity,parentName)),(InputName (childEntity, childName)))) + else (InputName (parentEntity, parentName + ":" + childName)) /// Canonical parts of splitAccountName @@ -214,7 +224,7 @@ type Account = struct else None member this.SubAccountsOrderedByInputName = - let namesAndAccounts = (this.SubAccounts |> Seq.sortBy (fun (KeyValue(_,account)) -> account.LastName.Input)) + let namesAndAccounts = (this.SubAccounts |> Seq.sortBy (fun (KeyValue(_,account)) -> account.LastName.Input.Name)) [for KeyValue(_, account) in namesAndAccounts -> account] end diff --git a/src/fsharp/Ledger/InternalTypesTest.fs b/src/fsharp/Ledger/InternalTypesTest.fs index 6c479a3..f22cb51 100644 --- a/src/fsharp/Ledger/InternalTypesTest.fs +++ b/src/fsharp/Ledger/InternalTypesTest.fs @@ -12,16 +12,16 @@ open PersistentCollections type ``Test Internal Types`` () = [] member test.``splitAccountName.`` () = - splitAccountName (InputName "Expenses:BankFees:AccountServiceFee") - |> should equal [{Canonical = (Canonical "EXPENSE"); Input = (Input "Expenses");}; - {Canonical = (Canonical "BANKFEES"); Input = (Input "BankFees");}; - {Canonical = (Canonical "ACCOUNTSERVICEFEE"); Input = (Input "AccountServiceFee");}] + splitAccountName (InputName(Entity "TEST_ENTITY", "Expenses:BankFees:AccountServiceFee")) + |> should equal [{Canonical = (Canonical (Entity "TEST_ENTITY", "EXPENSE")); Input = (InputName(Entity "TEST_ENTITY", "Expenses"));}; + {Canonical = (Canonical (Entity "TEST_ENTITY", "BANKFEES")); Input = (InputName(Entity "TEST_ENTITY", "BankFees"));}; + {Canonical = (Canonical (Entity "TEST_ENTITY", "ACCOUNTSERVICEFEE")); Input = (InputName(Entity "TEST_ENTITY", "AccountServiceFee"));}] [] member test.``Book posting to Account.``() = /// I think this might even work ... It did - on the first attempt. // It's a lot easier to write working code in F# than it is in python. - let a = Account(InputName "Assets") - let p = {Posting.account = (InputName "Assets:Bankwest:Cheque"); + let a = Account(InputName(Entity "TEST_ENTITY", "Assets")) + let p = {Posting.account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); Posting.amount = AUD 100000;} let t = {Transaction.id = 1; Transaction.date = "1999-12-31"; @@ -29,19 +29,19 @@ type ``Test Internal Types`` () = Transaction.description = "dummy"} let detail = {posting=p;transaction=t} let a2 = a.Book(p, t, (List.tail (splitAccountName p.account))) - a2.SubAccounts.ContainsKey(Canonical "BANKWEST") |> should be True - a2.SubAccounts.[Canonical "BANKWEST"].SubAccounts.ContainsKey(Canonical "CHEQUE") |> should be True + a2.SubAccounts.ContainsKey(Canonical (Entity "TEST_ENTITY", "BANKWEST")) |> should be True + a2.SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].SubAccounts.ContainsKey(Canonical (Entity "TEST_ENTITY", "CHEQUE")) |> should be True a2.Balance |> should equal (AUD 100000) - a2.SubAccounts.[Canonical "BANKWEST"].Balance |> should equal (AUD 100000) - a2.SubAccounts.[Canonical "BANKWEST"].SubAccounts.[Canonical "CHEQUE"].Balance |> should equal (AUD 100000) + a2.SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].Balance |> should equal (AUD 100000) + a2.SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].SubAccounts.[Canonical(Entity "TEST_ENTITY", "CHEQUE")].Balance |> should equal (AUD 100000) a2.Postings |> should equal PersistentQueue.Empty - a2.SubAccounts.[Canonical "BANKWEST"].Postings |> should equal PersistentQueue.Empty - a2.SubAccounts.[Canonical "BANKWEST"].SubAccounts.[Canonical "CHEQUE"].Postings |> should equal (PersistentQueue.Empty.Enqueue detail) + a2.SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].Postings |> should equal PersistentQueue.Empty + a2.SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "CHEQUE")].Postings |> should equal (PersistentQueue.Empty.Enqueue detail) [] member test.``Book posting to Accounts.``() = /// And this also seems to work on first attempt. let a = Accounts() - let p = {Posting.account = (InputName "Assets:Bankwest:Cheque"); + let p = {Posting.account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); Posting.amount = AUD 100000;} let t = {Transaction.id = 1; Transaction.date = "1999-12-31"; @@ -49,35 +49,35 @@ type ``Test Internal Types`` () = Transaction.description = "dummy"} let detail = {posting=p;transaction=t} let a2 = a.Book(p, t) - a2.Accounts.ContainsKey(Canonical "ASSETS") |> should be True - a2.Accounts.[Canonical "ASSETS"].SubAccounts.ContainsKey(Canonical "BANKWEST") |> should be True - a2.Accounts.[Canonical "ASSETS"].SubAccounts.[Canonical "BANKWEST"].SubAccounts.ContainsKey(Canonical "CHEQUE") |> should be True - a2.Accounts.[Canonical "ASSETS"].Balance |> should equal (AUD 100000) - a2.Accounts.[Canonical "ASSETS"].SubAccounts.[Canonical "BANKWEST"].Balance |> should equal (AUD 100000) - a2.Accounts.[Canonical "ASSETS"].SubAccounts.[Canonical "BANKWEST"].SubAccounts.[Canonical "CHEQUE"].Balance |> should equal (AUD 100000) - a2.Accounts.[Canonical "ASSETS"].Postings |> should equal PersistentQueue.Empty - a2.Accounts.[Canonical"ASSETS"].SubAccounts.[Canonical "BANKWEST"].Postings |> should equal PersistentQueue.Empty - a2.Accounts.[Canonical"ASSETS"].SubAccounts.[Canonical "BANKWEST"].SubAccounts.[Canonical "CHEQUE"].Postings |> should equal (PersistentQueue.Empty.Enqueue detail) + a2.Accounts.ContainsKey(Canonical (Entity "TEST_ENTITY", "ASSETS")) |> should be True + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].SubAccounts.ContainsKey(Canonical (Entity "TEST_ENTITY", "BANKWEST")) |> should be True + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].SubAccounts.ContainsKey(Canonical(Entity "TEST_ENTITY", "CHEQUE")) |> should be True + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].Balance |> should equal (AUD 100000) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].Balance |> should equal (AUD 100000) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "CHEQUE")].Balance |> should equal (AUD 100000) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].Postings |> should equal PersistentQueue.Empty + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].Postings |> should equal PersistentQueue.Empty + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "CHEQUE")].Postings |> should equal (PersistentQueue.Empty.Enqueue detail) [] member test.``Book transaction to Accounts.``() = // Maybe this is also right first time. let t = {Transaction.date = "2013-01-01"; description = "I began the year with $1000 in my cheque account."; // NB: Top-level account "Asset" gets created as "ASSET_S_" - postings = [{account = (InputName "Asset:Bankwest:Cheque"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Asset:Bankwest:Cheque")); amount = AUD 100000;}; - {account = (InputName "Equity:OpeningBalances"); + {account = (InputName(Entity "TEST_ENTITY", "Equity:OpeningBalances")); amount = AUD 100000;}]; id=1} let detail0 = {posting=t.postings.[0];transaction=t} let detail1 = {posting=t.postings.[1];transaction=t} let a = Accounts() let a2 = a.Book(t) - a2.Accounts.ContainsKey(Canonical "ASSETS") |> should be True - a2.Accounts.ContainsKey(Canonical "EQUITY") |> should be True - a2.Accounts.[Canonical "ASSETS"].Balance |> should equal (AUD 100000) - a2.Accounts.[Canonical "EQUITY"].Balance |> should equal (AUD 100000) - a2.Accounts.[Canonical "ASSETS"].Postings |> should equal (PersistentQueue.Empty) - a2.Accounts.[Canonical "EQUITY"].Postings |> should equal (PersistentQueue.Empty) - a2.Accounts.[Canonical "ASSETS"].SubAccounts.[Canonical "BANKWEST"].SubAccounts.[Canonical "CHEQUE"].Postings |> should equal (PersistentQueue.Empty.Enqueue detail0) - a2.Accounts.[Canonical "EQUITY"].SubAccounts.[Canonical "OPENINGBALANCES"].Postings |> should equal (PersistentQueue.Empty.Enqueue detail1) + a2.Accounts.ContainsKey(Canonical (Entity "TEST_ENTITY", "ASSETS")) |> should be True + a2.Accounts.ContainsKey(Canonical (Entity "TEST_ENTITY", "EQUITY")) |> should be True + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].Balance |> should equal (AUD 100000) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "EQUITY")].Balance |> should equal (AUD 100000) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].Postings |> should equal (PersistentQueue.Empty) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "EQUITY")].Postings |> should equal (PersistentQueue.Empty) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "ASSETS")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "BANKWEST")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "CHEQUE")].Postings |> should equal (PersistentQueue.Empty.Enqueue detail0) + a2.Accounts.[Canonical (Entity "TEST_ENTITY", "EQUITY")].SubAccounts.[Canonical (Entity "TEST_ENTITY", "OPENINGBALANCES")].Postings |> should equal (PersistentQueue.Empty.Enqueue detail1) diff --git a/src/fsharp/Ledger/Ledger.fsproj b/src/fsharp/Ledger/Ledger.fsproj index 70660e9..5e1d4e7 100644 --- a/src/fsharp/Ledger/Ledger.fsproj +++ b/src/fsharp/Ledger/Ledger.fsproj @@ -25,8 +25,10 @@ AnyCPU bin\Debug\Ledger.XML true - Little-Red-Hen.ledger chart-of-accounts - C:\Users\mafm\Desktop\working-directories\family-taxes + + + + pdbonly diff --git a/src/fsharp/Ledger/Misc.fs b/src/fsharp/Ledger/Misc.fs index 35e45dd..5e80b8c 100644 --- a/src/fsharp/Ledger/Misc.fs +++ b/src/fsharp/Ledger/Misc.fs @@ -1,22 +1,43 @@ -/// Stuff that should be built into the language, but I couldn't find. +/// Stuff that should be built into the language, but I couldn't find. + +module Misc -module Misc - -/// Remove occurences of chars from s -// http://rosettacode.org/wiki/Strip_a_set_of_characters_from_a_string#F.23 -let stripChars (chars:string) (s:string) = - Array.fold (fun (s:string) c -> s.Replace(c.ToString(),"")) - s (chars.ToCharArray()) - -/// Does list a start with list b? -let rec startsWith<'X when 'X: equality> (a :'X list) (b :'X list) = - match b with - | [] -> true - | B::Bs -> match a with - | A::As -> (A=B) && (startsWith As Bs) - | _ -> false - -// Is this actually useful anywhere? Can I use this in practice? -type NonEmptyList<'t> = - | One of 't - | More of first:'t * rest:NonEmptyList<'t> \ No newline at end of file +/// Remove occurences of chars from s +// http://rosettacode.org/wiki/Strip_a_set_of_characters_from_a_string#F.23 +let stripChars (chars:string) (s:string) = + Array.fold (fun (s:string) c -> s.Replace(c.ToString(),"")) + s (chars.ToCharArray()) + +/// Does list a start with list b? +let rec startsWith<'X when 'X: equality> (a :'X list) (b :'X list) = + match b with + | [] -> true + | B::Bs -> match a with + | A::As -> (A=B) && (startsWith As Bs) + | _ -> false + +// Is this actually useful anywhere? Can I use this in practice? +type NonEmptyList<'t> = + | One of 't + | More of first:'t * rest:NonEmptyList<'t> + +// Collapse functions. Basically collapses a list of tuples to a dictionary. I have chosen to return a list of tuples in the format (key*values[]) instead. +module List = + let collapse (kv:('a*'b) List) = + let data1, data2 = kv |> List.unzip + let keys = data1 |> Seq.distinct |> List.ofSeq + + keys + |> List.map (fun x -> (x, kv + |> List.filter (fun (k,v) -> k=x) + |> List.map snd)) + +module Array = + let collapse(kv:('a*'b)[]) = + let data1, data2 = kv |> Array.unzip + let keys = data1 |> Seq.distinct |> Array.ofSeq //there's a natice Array.distinct in newer versions of F#, but this project is targeting an older version + + keys + |> Array.map (fun x -> (x, kv + |> Array.filter (fun (k,v) -> k=x) + |> Array.map snd)) \ No newline at end of file diff --git a/src/fsharp/Ledger/Parse.fs b/src/fsharp/Ledger/Parse.fs index 8dfde87..27d3878 100644 --- a/src/fsharp/Ledger/Parse.fs +++ b/src/fsharp/Ledger/Parse.fs @@ -6,19 +6,28 @@ open Misc open FParsec -// XXX: Yuck! Can I do this without global state! -// Yes: http://www.quanttec.com/fparsec/users-guide/parsing-with-user-state.html -let mutable transactionId = 0 -let nextTransactionId () = - transactionId <- transactionId + 1 - transactionId -let resetTransactionId () = - transactionId <- 0 +type UserState = {TransactionId:int} + with + static member Default = + {TransactionId=0} + + member this.NextId (stream:CharStream) = + let next = {TransactionId=this.TransactionId+1} + stream.UserState <- next //there is no escaping mutables for UserState, unfortunately + next.TransactionId + + member this.ResetId (stream:CharStream) = + let next = {TransactionId=0} + stream.UserState <- next + next.TransactionId type ParseResult = | ParseError of string | ParseSuccess of InputFile +let run parser str = + runParserOnString parser UserState.Default "" str + let nonEolWhiteSpace = " \t" // At least one space let pMandatorySpace = @@ -27,10 +36,23 @@ let pMandatorySpace = let pOptionalSpace = (skipMany (anyOf nonEolWhiteSpace)) "space" +let pEntity = + let isEntityFirstChar c = isLetter c + let isEntityChar c = isLetter c || isDigit c || c = '-' || c = '_' || c = '.' //can't use '/' because that's our ending character + (many1Satisfy2L isEntityFirstChar isEntityChar "entity") .>>? skipString "/" + +let pOptEntity = + opt pEntity + |>> (fun optEntity -> + match optEntity with + | Some entity -> Entity entity + | None -> Default) + let pAccount = - let isAccountFirstChar c = isLetter c - let isAccountChar c = isLetter c || isDigit c || c = '-' || c = '_' || c = '/' || c = ':' || c = '.' - many1Satisfy2L isAccountFirstChar isAccountChar "account" + let isAccountFirstChar c = isLetter c + let isAccountChar c = isLetter c || isDigit c || c = '-' || c = '_' || c = '/' || c = ':' || c = '.' + pipe2 pOptEntity (many1Satisfy2L isAccountFirstChar isAccountChar "account") + (fun entity account -> InputName(entity, account)) let pAudAmount = let isAudAmountFirstChar c = isDigit c || c = '-' || c = '$' || c = '+' @@ -44,11 +66,11 @@ let pAmount = pAudAmount let pYear = - (pipe4 digit digit digit digit (fun a b c d -> System.String.Concat(Array.ofList([a;b;c;d])))) "year" + (pipe4 digit digit digit digit (fun a b c d -> System.String.Concat([|a;b;c;d|]))) "year" let pMonth = - (pipe2 digit digit (fun a b -> System.String.Concat(Array.ofList([a;b])))) "month" + (pipe2 digit digit (fun a b -> System.String.Concat([|a;b|]))) "month" let pDay = - (pipe2 digit digit (fun a b -> System.String.Concat(Array.ofList([a;b])))) "day" + (pipe2 digit digit (fun a b -> System.String.Concat([|a;b|]))) "day" let pDate = (pipe3 (pYear .>> (pchar '-')) (pMonth .>> (pchar '-')) @@ -68,26 +90,29 @@ let pVerifyBalance = (pMandatorySpace >>. pAccount) (pMandatorySpace >>. pAmount .>> pOptionalSpace .>> newline) (fun date account amount -> BalanceVerfication { BalanceVerfication.date = date - BalanceVerfication.account = (InputName account) + BalanceVerfication.account = account BalanceVerfication.amount = amount}) let pPosting = pipe2 (pOptionalSpace >>. pAccount) (pMandatorySpace >>. pAmount .>> pOptionalSpace .>> newline) - (fun account amount -> { Posting.account = (InputName account); + (fun account amount -> { Posting.account = account; Posting.amount = amount}) let pPostings = (many1 (attempt pPosting)) let pTransaction = - pipe3 pDate - (pMandatorySpace >>. (restOfLine false) .>> newline) - pPostings - (fun date description postings -> - Transaction { Transaction.date = date; - Transaction.description = description; - Transaction.postings = postings - Transaction.id = nextTransactionId()}) + fun (stream:CharStream) -> + let p = pipe3 + pDate + (pMandatorySpace >>. (restOfLine false) .>> newline) + pPostings + (fun date description postings -> + Transaction { Transaction.date = date; + Transaction.description = description; + Transaction.postings = postings + Transaction.id = stream.UserState.NextId stream}) + p stream let pInput = pOptionalSpace >>. (pCommentLine <|> pBlankLine <|> pVerifyBalance <|> pTransaction) @@ -101,8 +126,6 @@ let pInputFile = // Top-level parsing routine(s). let parseInputString str = - resetTransactionId() // XXX: Yuck! Can I do this without global state! - // Yes: http://www.quanttec.com/fparsec/users-guide/parsing-with-user-state.html match run pInputFile str with | Success(result, _, _) -> ParseSuccess(result) | Failure(errorMessage, _, _) -> ParseError(errorMessage) diff --git a/src/fsharp/Ledger/ParseTest.fs b/src/fsharp/Ledger/ParseTest.fs index a6ed80d..312f5fc 100644 --- a/src/fsharp/Ledger/ParseTest.fs +++ b/src/fsharp/Ledger/ParseTest.fs @@ -9,75 +9,75 @@ open InputTypes type ``Test Parsing of transaction text data`` () = [] member test.``parseTransactionData.`` () = - let parse = (parseInputString "2122-22-01 foo\n foo 10AUD\n bar 11\n baz 12\n2012-12-22 foo sss\nacc $10\n") in do + let parse = (parseInputString "2122-22-01 foo\n TEST_ENTITY/foo 10AUD\n TEST_ENTITY/bar 11\n TEST_ENTITY/baz 12\n2012-12-22 foo sss\nTEST_ENTITY/acc $10\n") in do parse |> should equal (ParseSuccess ([Transaction {date = "2122-22-01"; description = "foo"; - postings = [{account = (InputName "foo"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "foo")); amount = AUD 1000;}; - {account = (InputName "bar"); + {account = (InputName(Entity "TEST_ENTITY", "bar")); amount = AUD 1100;}; - {account = (InputName "baz"); + {account = (InputName(Entity "TEST_ENTITY", "baz")); amount = AUD 1200;}]; id=1}; Transaction {date = "2012-12-22"; description = "foo sss"; - postings = [{account = (InputName "acc"); + postings = [{account = (InputName(Entity "TEST_ENTITY","acc")); amount = AUD 1000;}]; id=2}])) [] member test.``Example from readme.md.`` () = let parse = (parseInputString ("""2013-01-01 I began the year with $1000 in my cheque account. - Assets:Bankwest:Cheque $1,000 - Equity:OpeningBalances $1,000 + TEST_ENTITY/Assets:Bankwest:Cheque $1,000 + TEST_ENTITY/Equity:OpeningBalances $1,000 2013-01-05 I bought some groceries and paid using the cheque account. - Expenses:Food:Groceries $98.53 - Assets:Bankwest:Cheque -$98.53 + TEST_ENTITY/Expenses:Food:Groceries $98.53 + TEST_ENTITY/Assets:Bankwest:Cheque -$98.53 2013-01-10 I bought some petrol, and paid using a credit card. - Expenses:Motor:Fuel $58.01 - Liabilities:Bankwest:Visa $58.01 + TEST_ENTITY/Expenses:Motor:Fuel $58.01 + TEST_ENTITY/Liabilities:Bankwest:Visa $58.01 2013-01-15 I paid my electricity bill. - Expenses:Electricity $280.42 - Assets:Bankwest:Cheque -$280.42 + TEST_ENTITY/Expenses:Electricity $280.42 + TEST_ENTITY/Assets:Bankwest:Cheque -$280.42 # I checked my bank statement on the 1st of Feb, and this is what it said. - VERIFY-BALANCE 2013-02-01 Assets:Bankwest:Cheque 621.05""" + "\n")) + VERIFY-BALANCE 2013-02-01 TEST_ENTITY/Assets:Bankwest:Cheque 621.05""" + "\n")) let expected = (ParseSuccess [Transaction {date = "2013-01-01"; description = "I began the year with $1000 in my cheque account."; - postings = [{account = (InputName "Assets:Bankwest:Cheque"); + postings = [{account = (InputName (Entity "TEST_ENTITY","Assets:Bankwest:Cheque")); amount = AUD 100000;}; - {account = (InputName "Equity:OpeningBalances"); + {account = (InputName (Entity "TEST_ENTITY","Equity:OpeningBalances")); amount = AUD 100000;}]; id=1}; BlankLine; Transaction {date = "2013-01-05"; description = "I bought some groceries and paid using the cheque account."; - postings = [{account = (InputName "Expenses:Food:Groceries"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Expenses:Food:Groceries")); amount = AUD 9853;}; - {account = (InputName "Assets:Bankwest:Cheque"); + {account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); amount = AUD -9853;}]; id=2}; BlankLine; Transaction {date = "2013-01-10"; description = "I bought some petrol, and paid using a credit card."; - postings = [{account = (InputName "Expenses:Motor:Fuel"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Expenses:Motor:Fuel")); amount = AUD 5801;}; - {account = (InputName "Liabilities:Bankwest:Visa"); + {account = (InputName(Entity "TEST_ENTITY", "Liabilities:Bankwest:Visa")); amount = AUD 5801;}]; id=3}; BlankLine; Transaction {date = "2013-01-15"; description = "I paid my electricity bill."; - postings = [{account = (InputName "Expenses:Electricity"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Expenses:Electricity")); amount = AUD 28042;}; - {account = (InputName "Assets:Bankwest:Cheque"); + {account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); amount = AUD -28042;}]; id=4}; BlankLine; Comment " I checked my bank statement on the 1st of Feb, and this is what it said."; BalanceVerfication {date = "2013-02-01"; - account = (InputName "Assets:Bankwest:Cheque"); + account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); amount = AUD 62105;}]) in do parse |> should equal expected diff --git a/src/fsharp/Ledger/Program.fs b/src/fsharp/Ledger/Program.fs index c8165ca..53848ca 100644 --- a/src/fsharp/Ledger/Program.fs +++ b/src/fsharp/Ledger/Program.fs @@ -21,12 +21,14 @@ /// This is a F# rewrite of almost the same thing in python: /// https://github.com/mafm/ledger.py +open FParsec open Parse open Calculations open InputTypes open InternalTypes open Misc open TextOutput +open System open FormatExceptionForDisplay @@ -74,7 +76,7 @@ let validateBalanceAssertions input = assertion.date (Text.fmt assertion.amount) (Text.fmt account.Balance)) - else None) + else None) if errors.Length <> 0 then errors |> List.iter nonFatal @@ -92,6 +94,24 @@ let validate (input: InputFile) = after transaction dated %s\n\ \t%s" next.date next.description prev.date prev.description) + // the side effect of checking for balanced number of entities is single-transaction bills get caught too, though + // a monetary balance check would have the same result + let unbalancedEntities = + transactions + |> verifyEntitiesInTransactions + + match unbalancedEntities with + | [] -> () + | _ -> + unbalancedEntities + |> List.iter (fun (entity,errors) -> + errors + |> List.iter (fun error -> nonFatal (sprintf "Unbalanced number of entity \"%s\" in transaction \"%s %s\"" error.AsString entity.date entity.description))) + fatal "Error in input file - unbalanced number of entities" + + // it's very important that we check for balanced entities first, so that the balance checks will clear without checking the incorrect entities + // this prevents issues like unbalanced entities being considered in the balance. Perhaps this could be refactored later so that "unbalanced" verifies entities? + // it may even need to happen soon, depending on how adding multi-entity support goes for me match (List.filter unbalanced transactions) with | [] -> () | unbalanced -> @@ -99,15 +119,16 @@ let validate (input: InputFile) = (nonFatal (sprintf "Imbalance of %s in transaction dated %s (%s)." (Text.fmt (absAmount (balance t))) t.date t.description)) (fatal "Error in input file - unbalanced transactions.") + (validateBalanceAssertions input) let demo () = try let timer = new System.Diagnostics.Stopwatch() - let input = parseInputFile "..\..\..\..\..\Examples\sample.transactions" in do + let input = parseInputFile "..\..\..\..\..\Examples\entity_sample.transactions" in do (validate input) printfn "Elapsed Time: %i ms.\n" timer.ElapsedMilliseconds - let report = (ReportRegister.generateReport input (InputName "Expenses")) in do + let report = (ReportRegister.generateReport input (InputName (Entity "TEST_ENTITY","Expenses"))) in do printf "Elapsed Time: %i ms.\n" timer.ElapsedMilliseconds (printf "\nDEMO: EXPENSES REGISTER\n") (ReportRegister.printRegisterReport report) @@ -154,6 +175,8 @@ let usage = """Ledger.fs: simple command-line double-entry accounting. let main argv = try let arguments = DocoptNet.Docopt().Apply(usage, argv, exit=true) + //let testargv = [|"..\..\..\..\..\Examples\entity_sample.transactions"; "transactions"; "2013-01-05"; "2013-01-10"|] + //let arguments = DocoptNet.Docopt().Apply(usage, testargv) let inputFileName = (string arguments.[""]) let dates = ([for d in arguments.[""].AsList -> (string d)] |> List.sort) let firstDate = match (string arguments.[""]) with | "" -> None | date -> Some date @@ -161,34 +184,37 @@ let main argv = let input = (parseInputFile inputFileName) (validate input) let destination = ExcelOutput.destination(string arguments.["--excel-output"]) - + if (arguments.["running-balance"].IsTrue) then - let report = (ReportRegister.generateReport input (InputName (string arguments.[""]))) - (ReportRegister.printRegisterReport report) - ExcelOutput.Excel.write(report, destination) - + match run (pAccount |>> fun acc -> acc) (string arguments.[""]) with + | Success(result, _, _) -> + let report = (ReportRegister.generateReport input result) + (ReportRegister.printRegisterReport report) + ExcelOutput.Excel.write(report, destination) + | Failure(errorMessage, _, _) -> fatal errorMessage + if (arguments.["balances"].IsTrue) then let report = (ReportBalances.generateReport input) (ReportBalances.printBalanceReport report) ExcelOutput.Excel.write(report, destination) - + if (arguments.["balances-by-date"].IsTrue) then if dates.Length < 1 then fatal("balances-by-date requires at least one date.") let report = (ReportBalancesByDate.generateReport input dates) (ReportBalancesByDate.printReport report) ExcelOutput.Excel.write(report, destination) - + if (arguments.["chart-of-accounts"].IsTrue) then let report = (ReportChartOfAccounts.generateReport input) (ReportChartOfAccounts.printReport report) ExcelOutput.Excel.write(report, destination) - + if (arguments.["transactions"].IsTrue) then let report = (ReportTransactionList.generateReport input firstDate lastDate) (ReportTransactionList.printReport report) ExcelOutput.Excel.write(report, destination) - + if (arguments.["summary"].IsTrue) then if dates.Length < 1 then fatal("summary requires at least one date.") @@ -198,13 +224,16 @@ let main argv = ExcelOutput.Excel.write((ReportBalancesByDate.generateReport input dates), destination) ExcelOutput.Excel.write((ReportChartOfAccounts.generateReport input), destination) ExcelOutput.Excel.write((ReportTransactionList.generateReport input None lastDate), destination) - + ExcelOutput.save(destination) 0 with | UnableToParseFile(filename, message) -> fatal(sprintf "Error parsing input file '%s' : %s" filename message) -1 + | :? EntityMismatch as e -> + fatal(e.ToString) + -1 | :? System.IO.IOException as e -> fatal(sprintf "IO error: %s" e.Message) -1 diff --git a/src/fsharp/Ledger/RegisterReportTest.fs b/src/fsharp/Ledger/RegisterReportTest.fs index c960140..690a4d4 100644 --- a/src/fsharp/Ledger/RegisterReportTest.fs +++ b/src/fsharp/Ledger/RegisterReportTest.fs @@ -15,55 +15,55 @@ type ``Test Register Report`` () = member test.``Test Expenses.`` () = let input = [Transaction {date = "2013-01-01"; description = "I began the year with $1000 in my cheque account."; - postings = [{account = (InputName "Assets:Bankwest:Cheque"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); amount = AUD 100000;}; - {account = (InputName "Equity:OpeningBalances"); + {account = (InputName(Entity "TEST_ENTITY", "Equity:OpeningBalances")); amount = AUD 100000;}]; id=1}; BlankLine; Transaction {date = "2013-01-05"; description = "I bought some groceries and paid using the cheque account."; - postings = [{account = (InputName "Expenses:Food:Groceries"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Expenses:Food:Groceries")); amount = AUD 9853;}; - {account = (InputName "Assets:Bankwest:Cheque"); + {account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); amount = AUD -9853;}]; id=2}; BlankLine; Transaction {date = "2013-01-10"; description = "I bought some petrol, and paid using a credit card."; - postings = [{account = (InputName "Expenses:Motor:Fuel"); - amount = AUD 5801;}; {account = (InputName "Liabilities:Bankwest:Visa"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Expenses:Motor:Fuel")); + amount = AUD 5801;}; {account = (InputName(Entity "TEST_ENTITY", "Liabilities:Bankwest:Visa")); amount = AUD 5801;}]; id=3}; BlankLine; Transaction {date = "2013-01-15"; description = "I paid my electricity bill."; - postings = [{account = (InputName "Expenses:Electricity"); + postings = [{account = (InputName(Entity "TEST_ENTITY", "Expenses:Electricity")); amount = AUD 28042;}; - {account = (InputName "Assets:Bankwest:Cheque"); + {account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); amount = AUD -28042;}]; id=4}; BlankLine; Comment " I checked my bank statement on the 1st of Feb, and this is what it said."; BalanceVerfication {date = "2013-02-01"; - account = (InputName "Assets:Bankwest:Cheque"); + account = (InputName(Entity "TEST_ENTITY", "Assets:Bankwest:Cheque")); amount = AUD 62105;}] - let expected = {Report.account = (InputName "Expenses"); + let expected = {Report.account = (InputName(Entity "TEST_ENTITY", "Expenses")); from = None; until = None; lines = [{date = "2013-01-05"; amount = AUD 9853; description = "I bought some groceries and paid using the cheque account."; - account = (InputName "Expenses:Food:Groceries"); + account = (InputName(Entity "TEST_ENTITY", "Expenses:Food:Groceries")); balance = AUD 9853;}; {date = "2013-01-10"; amount = AUD 5801; description = "I bought some petrol, and paid using a credit card."; - account = (InputName "Expenses:Motor:Fuel"); + account = (InputName(Entity "TEST_ENTITY", "Expenses:Motor:Fuel")); balance = AUD 15654;}; {date = "2013-01-15"; amount = AUD 28042; description = "I paid my electricity bill."; - account = (InputName "Expenses:Electricity"); + account = (InputName(Entity "TEST_ENTITY", "Expenses:Electricity")); balance = AUD 43696;}];} - (generateReport input (InputName "Expenses")) |> should equal expected + (generateReport input (InputName(Entity "TEST_ENTITY", "Expenses"))) |> should equal expected diff --git a/src/fsharp/Ledger/ReportBalanceSheet.fs b/src/fsharp/Ledger/ReportBalanceSheet.fs index 30a6f52..ee79096 100644 --- a/src/fsharp/Ledger/ReportBalanceSheet.fs +++ b/src/fsharp/Ledger/ReportBalanceSheet.fs @@ -56,7 +56,7 @@ let rec constructReportBalanceSheetLine (accounts : Account option List) (accoun | Some account -> account.Balance | None -> zeroAmount) accounts) - { Account = (toInputName accountTree.Name) + { Account = InputName (accountTree.Name.[0].Input.Entity, Text.fmt accountTree.Name) //XXX: Ugh, I can't believe I keep doing this. There has to be a better way....... Amounts = { Balances = balances } SubAccounts = @@ -64,7 +64,7 @@ let rec constructReportBalanceSheetLine (accounts : Account option List) (accoun (constructReportBalanceSheetLine [ for a in accounts -> (extractSubAccount a child.Name) ] child) ] Postings = accountTree.Postings } -let addLine (name: InputNameAccount) (accounts: DatedAccounts) (dates: Date list) (linesSoFar : List) = +let addLine (accounts: DatedAccounts) (dates: Date list) (name: InputNameAccount) linesSoFar = let lastDate = (List.max dates) let finalAccounts = accounts.[lastDate] in match finalAccounts.find(name) with @@ -78,10 +78,13 @@ let addLine (name: InputNameAccount) (accounts: DatedAccounts) (dates: Date list let generateReport (input: InputFile) (dates: Date list) = let datedAccounts = (accountsByDate input dates) - { Dates = dates; - Lines = (addLine (InputName "Assets") datedAccounts dates - (addLine (InputName "Liabilities") datedAccounts dates - (addLine (InputName "Equity") datedAccounts dates [])))} + let addLine = addLine datedAccounts dates + let lines = [for account in datedAccounts.[dates|>List.max].Accounts -> account.Value.FullInputName] + |> List.sortBy (fun x -> x.Entity.AsString) + |> List.fold (fun (acc:Line list) (elem:InputNameAccount) -> + addLine(elem) acc) [] + {Dates = dates; + Lines = lines} let rec printReportLine indent (line : Line) = for balance in line.Amounts.Balances do @@ -89,7 +92,7 @@ let rec printReportLine indent (line : Line) = for i in 1 .. indent do printf " " match line.Account with - (InputName str) -> printf "%s\n" str + (InputName (entity,name)) -> printf "%s\n" name for subLine in line.SubAccounts do printReportLine (indent+2) subLine diff --git a/src/fsharp/Ledger/ReportBalances.fs b/src/fsharp/Ledger/ReportBalances.fs index 04cacaa..f92e46e 100644 --- a/src/fsharp/Ledger/ReportBalances.fs +++ b/src/fsharp/Ledger/ReportBalances.fs @@ -17,7 +17,7 @@ type Line = Balance : Amount SubAccounts : Line list } -type Report = {lines: Line list} +type Report = {Lines: Line list} let rec accountBalanceReport (name:InputNameAccount) (a: Account) = let subAccounts = [for KeyValue(name, account) in (a.SubAccounts|>Seq.sortBy (fun (KeyValue(k,_)) -> k) ) -> account] @@ -29,7 +29,7 @@ let rec accountBalanceReport (name:InputNameAccount) (a: Account) = -> (accountBalanceReport (joinInputNames name onlyChild.LastName.Input) onlyChild) | _ -> { Account = name; Balance = a.Balance; - SubAccounts = [for account in subAccounts -> (accountBalanceReport account.LastName.Input.AsInputName account)]} + SubAccounts = [for account in subAccounts -> (accountBalanceReport account.LastName.Input account)]} let generateReport (input: InputFile) = let accounts = Accounts(transactions input) @@ -37,11 +37,10 @@ let generateReport (input: InputFile) = match (accounts.find account) with | None -> lines | Some a -> (accountBalanceReport a.FullInputName a)::lines - {lines = (addLine (InputName "Assets") - (addLine (InputName "Liabilities") - (addLine (InputName "Income") - (addLine (InputName "Expenses") - (addLine (InputName "Equity") [])))))} + {Lines=([for account in accounts.Accounts -> + account.Key.AsInputName] + |> List.fold (fun (acc:Line list) (elem:InputNameAccount) -> + addLine(elem) acc) [])} let rec printBalanceReportLine indent (line : Line) = printf "%s\t" (Text.fmt line.Balance) @@ -53,5 +52,5 @@ let rec printBalanceReportLine indent (line : Line) = let printBalanceReport report = printf "Balance\tAccount\n-------\t-------\n" - for line in report.lines do + for line in report.Lines do printBalanceReportLine 0 line diff --git a/src/fsharp/Ledger/ReportBalancesByDates.fs b/src/fsharp/Ledger/ReportBalancesByDates.fs index 813bbdd..309b94f 100644 --- a/src/fsharp/Ledger/ReportBalancesByDates.fs +++ b/src/fsharp/Ledger/ReportBalancesByDates.fs @@ -57,7 +57,7 @@ let rec constructReportBalancesByDateLine (accounts : Account option List) (acco | Some account -> account.Balance | None -> zeroAmount) accounts) - { Account = (InputName (Text.fmt accountTree.Name)) + { Account = InputName (accountTree.Name.[0].Input.Entity, Text.fmt accountTree.Name) //XXX: Ugh, I can't believe I keep doing this. There has to be a better way....... Amounts = { Balances = balances Differences = (differences balances) } @@ -67,7 +67,7 @@ let rec constructReportBalancesByDateLine (accounts : Account option List) (acco (constructReportBalancesByDateLine childAccounts child) ] Postings = accountTree.Postings } -let addLine (name: InputNameAccount) (accounts: DatedAccounts) (dates: Date list) linesSoFar = +let addLine (accounts: DatedAccounts) (dates: Date list) (name: InputNameAccount) linesSoFar = let lastDate = (List.max dates) let finalAccounts = accounts.[lastDate] in match finalAccounts.find(name) with @@ -80,12 +80,14 @@ let addLine (name: InputNameAccount) (accounts: DatedAccounts) (dates: Date list let generateReport (input: InputFile) (dates: Date list) = let datedAccounts = (accountsByDate input dates) + let addLine = addLine datedAccounts dates + let lines = [for account in datedAccounts.[dates|>List.max].Accounts -> account.Value.FullInputName] + |> List.sortBy (fun x -> x.Entity) + |> List.fold (fun (acc:Line list) (elem:InputNameAccount) -> + addLine(elem) acc) [] + {Dates = dates; - Lines = (addLine (InputName "Assets") datedAccounts dates - (addLine (InputName "Liabilities") datedAccounts dates - (addLine (InputName "Income") datedAccounts dates - (addLine (InputName "Expenses") datedAccounts dates - (addLine (InputName "Equity") datedAccounts dates [])))))} + Lines = lines} let rec printReportLine indent (line : Line) = for balance in line.Amounts.Balances do @@ -94,7 +96,7 @@ let rec printReportLine indent (line : Line) = printf "%s\t" (Text.fmt difference) for i in 1 .. indent do printf " " - printf "%s\n" line.Account.AsString + printf "%s/%s\n" line.Account.Entity.AsString line.Account.Name for subLine in line.SubAccounts do printReportLine (indent+2) subLine diff --git a/src/fsharp/Ledger/ReportChartOfAccounts.fs b/src/fsharp/Ledger/ReportChartOfAccounts.fs index 54212ca..a8e203a 100644 --- a/src/fsharp/Ledger/ReportChartOfAccounts.fs +++ b/src/fsharp/Ledger/ReportChartOfAccounts.fs @@ -21,30 +21,40 @@ let rec constructLine (account: Account) = SubAccounts = [for child in subAccounts -> (constructLine child)]} -let addLine (name: InputNameAccount) (accounts: Accounts) linesSoFar = +let addLine (accounts: Accounts) (name: InputNameAccount) linesSoFar = match accounts.find(name) with | Some account -> ((constructLine account) :: linesSoFar) | None -> linesSoFar let generateReport (input: InputFile) = let accounts = (Accounts (transactions input)) - {Lines = (addLine (InputName "Assets") accounts - (addLine (InputName "Liabilities") accounts - (addLine (InputName "Income") accounts - (addLine (InputName "Expenses") accounts - (addLine (InputName "Equity") accounts [])))))} - + let addLine = addLine accounts //partial application, one of the greatest features in F#, allows for cleaner code when we're simply passing multiple static variables + let lines = [for account in accounts.Accounts -> account.Key.AsInputName] + |> List.sortBy (fun x -> x.Entity) + |> List.fold (fun (acc:Line list) (elem:InputNameAccount) -> + addLine elem acc) [] + {Lines=lines} + let rec printLine indent (line : Line) = for i in 1 .. indent do printf " " match line.Account with - (InputName str) -> printf "%s\n" str + (InputName (entity,name)) -> printf "%s\n" name for subLine in line.SubAccounts do printLine (indent+2) subLine let printReport report = (* Balance/Change headings line *) printf "Account\n" - printf "-------\n" - for line in report.Lines do - printLine 0 line \ No newline at end of file + printf "-------\n" + + let collapsed = + report.Lines + |> List.map (fun l -> (l.Account.Entity, l)) + |> List.collapse + + for (entity,lines) in collapsed do + printfn "%s/" entity.AsString + for line in lines do + printLine 1 line + printfn "" \ No newline at end of file diff --git a/src/fsharp/Ledger/ReportProfitAndLoss.fs b/src/fsharp/Ledger/ReportProfitAndLoss.fs index 020cb59..fb15318 100644 --- a/src/fsharp/Ledger/ReportProfitAndLoss.fs +++ b/src/fsharp/Ledger/ReportProfitAndLoss.fs @@ -61,7 +61,7 @@ let rec constructReportProfitAndLossLine (accounts : Account option List) (accou | Some account -> account.Balance | None -> zeroAmount) accounts) - { Account = (InputName (Text.fmt accountTree.Name)) + { Account = InputName (accountTree.Name.[0].Input.Entity, Text.fmt accountTree.Name) //XXX: Ugh, I can't believe I keep doing this. There has to be a better way....... Amounts = { Differences = (differences balances) } SubAccounts = @@ -69,7 +69,7 @@ let rec constructReportProfitAndLossLine (accounts : Account option List) (accou (constructReportProfitAndLossLine [ for a in accounts -> (extractSubAccount a child.Name) ] child) ] Postings = accountTree.Postings } -let addLine (name: InputNameAccount) (accounts: DatedAccounts) (dates: Date list) linesSoFar = +let addLine (accounts: DatedAccounts) (dates: Date list) (name: InputNameAccount) linesSoFar = let lastDate = (List.max dates) let finalAccounts = accounts.[lastDate] in match finalAccounts.find(name) with @@ -80,9 +80,16 @@ let addLine (name: InputNameAccount) (accounts: DatedAccounts) (dates: Date list let generateReport (input: InputFile) (dates: Date list) = let datedAccounts = (accountsByDate input dates) + let addLine = addLine datedAccounts dates + let lines = [for account in datedAccounts.[dates|>List.max].Accounts -> account.Value.FullInputName] + |> List.filter (fun x -> + let accountType = accountType x + (accountType = Income || accountType = Expense)) + |> List.sortBy (fun x -> x.Entity.AsString) + |> List.fold (fun (acc:Line list) (elem:InputNameAccount) -> + addLine(elem) acc) [] {Dates = dates; - Lines = (addLine (InputName "Income") datedAccounts dates - (addLine (InputName "Expenses") datedAccounts dates []))} + Lines = lines} let rec printReportLine indent (line : Line) = for difference in line.Amounts.Differences do diff --git a/src/fsharp/Ledger/TextOutput.fs b/src/fsharp/Ledger/TextOutput.fs index 5d9d455..b7666da 100644 --- a/src/fsharp/Ledger/TextOutput.fs +++ b/src/fsharp/Ledger/TextOutput.fs @@ -10,14 +10,17 @@ open InternalTypes type Text = static member fmt (x:InternalNameAccount) = - match x with - | last::[] -> - match last.Input with - (Input name) -> (sprintf "%s" name) - | first::rest -> - match first.Input with - (Input name) -> (sprintf "%s:%s" name (Text.fmt rest)) - | [] -> raise EmptyAccountNameComponentsException + let rec helper (y:InternalNameAccount) = + match y with + | last::[] -> + match last.Input with + (InputName (entity, name)) -> (sprintf "%s" name) + | first::rest -> + match first.Input with + (InputName (entity, name)) -> (sprintf "%s:%s" name (helper rest)) + | [] -> raise EmptyAccountNameComponentsException + (helper x) + static member fmt (x: Amount) = match x with | AUD 0 -> "-"