From 7a29a2bf7f60b449fc3f4e8ac83d062368219f60 Mon Sep 17 00:00:00 2001 From: Nikki Locke Date: Mon, 9 Nov 2015 14:22:55 +0000 Subject: [PATCH] Initial checkin --- .gitignore | 7 + AccountServer.csproj | 105 + AccountServer.sln | 20 + Accounting.cs | 424 + AppModule.cs | 1061 + Banking.cs | 884 + BankingAccounting.cs | 140 + Company.cs | 218 + CsvParser.cs | 300 + Customer.cs | 169 + CustomerSupplier.cs | 489 + DDLAttributes.cs | 281 + Database.cs | 1451 ++ DbInterface.cs | 71 + Importer.cs | 661 + Investments.cs | 533 + JsonClasses.cs | 685 + MySqlDatabase.cs | 372 + Program.cs | 180 + Properties/AssemblyInfo.cs | 36 + QifImporter.cs | 800 + Query.cs | 60 + README.md | 128 +- Reports.cs | 2138 ++ SQLiteDatabase.cs | 494 + Select.cs | 153 + Settings.cs | 271 + Supplier.cs | 43 + Utils.cs | 444 + WebServer.cs | 199 + html/Query/default.html | 19 + html/accounting/default.html | 33 + html/accounting/detail.html | 78 + html/accounting/document.html | 196 + html/accounting/test.js | 23 + html/accounting/vatreturn.html | 210 + html/admin/batch.html | 30 + html/admin/default.html | 135 + html/admin/import.html | 15 + html/admin/importhelp.html | 30 + html/admin/integritycheck.html | 3 + html/admin/restore.html | 7 + html/banking/default.html | 32 + html/banking/detail.html | 120 + html/banking/document.html | 263 + html/banking/importhelp.html | 22 + html/banking/name.html | 45 + html/banking/names.html | 34 + html/banking/reconcile.html | 163 + html/banking/statementimport.html | 38 + html/banking/statementmatching.html | 173 + html/banking/transfer.html | 83 + html/bowser.js | 239 + html/company/default.html | 135 + html/company/job.html | 83 + html/company/schedule.html | 22 + html/customer/default.html | 33 + html/customer/detail.html | 79 + html/customer/document.html | 262 + html/customer/email.txt | 5 + html/customer/payment.html | 164 + html/customer/paymenthistory.html | 62 + html/customer/print.html | 136 + html/customer/product.html | 56 + html/customer/products.html | 25 + html/customer/vatcode.html | 33 + html/customer/vatcodes.html | 22 + html/default.css | 222 + html/default.html | 44 + html/default.js | 2860 +++ html/exception.html | 17 + html/favicon.ico | Bin 0 -> 169468 bytes html/images/close.png | Bin 0 -> 211 bytes html/images/menu.png | Bin 0 -> 220 bytes html/images/nz.png | Bin 0 -> 349 bytes html/images/sort_asc.png | Bin 0 -> 1118 bytes html/images/sort_asc_disabled.png | Bin 0 -> 2916 bytes html/images/sort_both.png | Bin 0 -> 1136 bytes html/images/sort_desc.png | Bin 0 -> 1127 bytes html/images/sort_desc_disabled.png | Bin 0 -> 1045 bytes html/images/tick.png | Bin 0 -> 270 bytes html/images/ui-bg_flat_0_aaaaaa_40x100.png | Bin 0 -> 212 bytes html/images/ui-bg_flat_75_ffffff_40x100.png | Bin 0 -> 208 bytes html/images/ui-bg_glass_55_fbf9ee_1x400.png | Bin 0 -> 335 bytes html/images/ui-bg_glass_65_ffffff_1x400.png | Bin 0 -> 207 bytes html/images/ui-bg_glass_75_dadada_1x400.png | Bin 0 -> 262 bytes html/images/ui-bg_glass_75_e6e6e6_1x400.png | Bin 0 -> 262 bytes html/images/ui-bg_glass_95_fef1ec_1x400.png | Bin 0 -> 332 bytes .../ui-bg_highlight-soft_75_cccccc_1x100.png | Bin 0 -> 280 bytes html/images/ui-icons_222222_256x240.png | Bin 0 -> 6922 bytes html/images/ui-icons_2e83ff_256x240.png | Bin 0 -> 4549 bytes html/images/ui-icons_454545_256x240.png | Bin 0 -> 6992 bytes html/images/ui-icons_888888_256x240.png | Bin 0 -> 6999 bytes html/images/ui-icons_cd0a0a_256x240.png | Bin 0 -> 4549 bytes html/images/untick.png | Bin 0 -> 182 bytes html/images/z.png | Bin 0 -> 321 bytes html/investments/balanceadjustment.html | 90 + html/investments/default.html | 32 + html/investments/detail.html | 85 + html/investments/document.html | 129 + html/investments/portfolio.html | 45 + html/investments/securities.html | 22 + html/investments/security.html | 76 + html/jquery-ui.js | 16582 ++++++++++++++++ html/jquery-ui.min.css | 7 + html/jquery-ui.min.js | 13 + html/jquery-ui.structure.css | 833 + html/jquery-ui.structure.min.css | 5 + html/jquery-ui.theme.css | 414 + html/jquery-ui.theme.min.css | 5 + html/jquery.dataTables.css | 476 + html/jquery.dataTables.js | 14840 ++++++++++++++ html/jquery.js | 10337 ++++++++++ html/jquery.multiselect.css | 23 + html/jquery.multiselect.js | 705 + html/jsdoc.js | 15 + html/report.js | 298 + html/reports/accounts.html | 30 + html/reports/ageing.html | 43 + html/reports/balancesheet.html | 54 + html/reports/default.html | 16 + html/reports/journals.html | 30 + html/reports/names.html | 44 + html/reports/products.html | 32 + html/reports/profitandloss.html | 54 + html/reports/reportbody.html | 23 + html/reports/securities.html | 32 + html/reports/transactions.html | 30 + html/reports/trialbalance.html | 39 + html/reports/vatcodes.html | 32 + html/reports/vatdetail.html | 30 + html/skin/default.css | 1 + html/skin/default.js | 1 + html/skin/styled.css | 39 + html/skin/styled.js | 1 + html/supplier/default.html | 34 + html/supplier/detail.html | 81 + html/supplier/document.html | 251 + html/supplier/payment.html | 166 + html/supplier/paymenthistory.html | 62 + html/underscore.js | 1276 ++ 141 files changed, 66499 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AccountServer.csproj create mode 100644 AccountServer.sln create mode 100644 Accounting.cs create mode 100644 AppModule.cs create mode 100644 Banking.cs create mode 100644 BankingAccounting.cs create mode 100644 Company.cs create mode 100644 CsvParser.cs create mode 100644 Customer.cs create mode 100644 CustomerSupplier.cs create mode 100644 DDLAttributes.cs create mode 100644 Database.cs create mode 100644 DbInterface.cs create mode 100644 Importer.cs create mode 100644 Investments.cs create mode 100644 JsonClasses.cs create mode 100644 MySqlDatabase.cs create mode 100644 Program.cs create mode 100644 Properties/AssemblyInfo.cs create mode 100644 QifImporter.cs create mode 100644 Query.cs create mode 100644 Reports.cs create mode 100644 SQLiteDatabase.cs create mode 100644 Select.cs create mode 100644 Settings.cs create mode 100644 Supplier.cs create mode 100644 Utils.cs create mode 100644 WebServer.cs create mode 100644 html/Query/default.html create mode 100644 html/accounting/default.html create mode 100644 html/accounting/detail.html create mode 100644 html/accounting/document.html create mode 100644 html/accounting/test.js create mode 100644 html/accounting/vatreturn.html create mode 100644 html/admin/batch.html create mode 100644 html/admin/default.html create mode 100644 html/admin/import.html create mode 100644 html/admin/importhelp.html create mode 100644 html/admin/integritycheck.html create mode 100644 html/admin/restore.html create mode 100644 html/banking/default.html create mode 100644 html/banking/detail.html create mode 100644 html/banking/document.html create mode 100644 html/banking/importhelp.html create mode 100644 html/banking/name.html create mode 100644 html/banking/names.html create mode 100644 html/banking/reconcile.html create mode 100644 html/banking/statementimport.html create mode 100644 html/banking/statementmatching.html create mode 100644 html/banking/transfer.html create mode 100644 html/bowser.js create mode 100644 html/company/default.html create mode 100644 html/company/job.html create mode 100644 html/company/schedule.html create mode 100644 html/customer/default.html create mode 100644 html/customer/detail.html create mode 100644 html/customer/document.html create mode 100644 html/customer/email.txt create mode 100644 html/customer/payment.html create mode 100644 html/customer/paymenthistory.html create mode 100644 html/customer/print.html create mode 100644 html/customer/product.html create mode 100644 html/customer/products.html create mode 100644 html/customer/vatcode.html create mode 100644 html/customer/vatcodes.html create mode 100644 html/default.css create mode 100644 html/default.html create mode 100644 html/default.js create mode 100644 html/exception.html create mode 100644 html/favicon.ico create mode 100644 html/images/close.png create mode 100644 html/images/menu.png create mode 100644 html/images/nz.png create mode 100644 html/images/sort_asc.png create mode 100644 html/images/sort_asc_disabled.png create mode 100644 html/images/sort_both.png create mode 100644 html/images/sort_desc.png create mode 100644 html/images/sort_desc_disabled.png create mode 100644 html/images/tick.png create mode 100644 html/images/ui-bg_flat_0_aaaaaa_40x100.png create mode 100644 html/images/ui-bg_flat_75_ffffff_40x100.png create mode 100644 html/images/ui-bg_glass_55_fbf9ee_1x400.png create mode 100644 html/images/ui-bg_glass_65_ffffff_1x400.png create mode 100644 html/images/ui-bg_glass_75_dadada_1x400.png create mode 100644 html/images/ui-bg_glass_75_e6e6e6_1x400.png create mode 100644 html/images/ui-bg_glass_95_fef1ec_1x400.png create mode 100644 html/images/ui-bg_highlight-soft_75_cccccc_1x100.png create mode 100644 html/images/ui-icons_222222_256x240.png create mode 100644 html/images/ui-icons_2e83ff_256x240.png create mode 100644 html/images/ui-icons_454545_256x240.png create mode 100644 html/images/ui-icons_888888_256x240.png create mode 100644 html/images/ui-icons_cd0a0a_256x240.png create mode 100644 html/images/untick.png create mode 100644 html/images/z.png create mode 100644 html/investments/balanceadjustment.html create mode 100644 html/investments/default.html create mode 100644 html/investments/detail.html create mode 100644 html/investments/document.html create mode 100644 html/investments/portfolio.html create mode 100644 html/investments/securities.html create mode 100644 html/investments/security.html create mode 100644 html/jquery-ui.js create mode 100644 html/jquery-ui.min.css create mode 100644 html/jquery-ui.min.js create mode 100644 html/jquery-ui.structure.css create mode 100644 html/jquery-ui.structure.min.css create mode 100644 html/jquery-ui.theme.css create mode 100644 html/jquery-ui.theme.min.css create mode 100644 html/jquery.dataTables.css create mode 100644 html/jquery.dataTables.js create mode 100644 html/jquery.js create mode 100644 html/jquery.multiselect.css create mode 100644 html/jquery.multiselect.js create mode 100644 html/jsdoc.js create mode 100644 html/report.js create mode 100644 html/reports/accounts.html create mode 100644 html/reports/ageing.html create mode 100644 html/reports/balancesheet.html create mode 100644 html/reports/default.html create mode 100644 html/reports/journals.html create mode 100644 html/reports/names.html create mode 100644 html/reports/products.html create mode 100644 html/reports/profitandloss.html create mode 100644 html/reports/reportbody.html create mode 100644 html/reports/securities.html create mode 100644 html/reports/transactions.html create mode 100644 html/reports/trialbalance.html create mode 100644 html/reports/vatcodes.html create mode 100644 html/reports/vatdetail.html create mode 100644 html/skin/default.css create mode 100644 html/skin/default.js create mode 100644 html/skin/styled.css create mode 100644 html/skin/styled.js create mode 100644 html/supplier/default.html create mode 100644 html/supplier/detail.html create mode 100644 html/supplier/document.html create mode 100644 html/supplier/payment.html create mode 100644 html/supplier/paymenthistory.html create mode 100644 html/underscore.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e4c7c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/html/.idea +/Debug +/Release +/obj +/packages +/libs +/*.suo diff --git a/AccountServer.csproj b/AccountServer.csproj new file mode 100644 index 0000000..becd7a1 --- /dev/null +++ b/AccountServer.csproj @@ -0,0 +1,105 @@ + + + + + Debug + AnyCPU + {1681E2F3-7956-45F8-A650-48742C192102} + Exe + Properties + AccountServer + AccountServer + v4.5 + 512 + + + AnyCPU + true + full + false + Debug\ + DEBUG;TRACE + prompt + 4 + false + + + AnyCPU + pdbonly + true + Release\ + TRACE + prompt + 4 + + + + libs\Mono.Data.Sqlite.dll + + + packages\mustache-sharp.0.2.8.1\lib\net40\mustache-sharp.dll + True + + + packages\MySql.Data.6.9.5\lib\net45\MySql.Data.dll + + + packages\Newtonsoft.Json.6.0.7\lib\net45\Newtonsoft.Json.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Code + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AccountServer.sln b/AccountServer.sln new file mode 100644 index 0000000..de27434 --- /dev/null +++ b/AccountServer.sln @@ -0,0 +1,20 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Express 2012 for Windows Desktop +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountServer", "AccountServer.csproj", "{1681E2F3-7956-45F8-A650-48742C192102}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1681E2F3-7956-45F8-A650-48742C192102}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1681E2F3-7956-45F8-A650-48742C192102}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1681E2F3-7956-45F8-A650-48742C192102}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1681E2F3-7956-45F8-A650-48742C192102}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Accounting.cs b/Accounting.cs new file mode 100644 index 0000000..bf89ae2 --- /dev/null +++ b/Accounting.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Accounting module has some functionality in common with Banking (e.g. NameAddress maintenance) + /// + public class Accounting : BankingAccounting { + public Accounting() { + Menu = new MenuOption[] { + new MenuOption("Listing", "/accounting/default.html"), + new MenuOption("Names", "/accounting/names.html"), + new MenuOption("VAT Return", "/accounting/vatreturn.html?id=0"), + new MenuOption("New Account", "/accounting/detail.html?id=0"), + new MenuOption("New Journal", "/accounting/document.html?id=0") + }; + } + + /// + /// List all non-protected accounts + /// + public object DefaultListing() { + return Database.Query("*", + "WHERE Protected = 0 ORDER BY AccountTypeId, AccountName", + "Account"); + } + + /// + /// Retrieve data for editing an account + /// + public void Detail(int id) { + FullAccount account = Database.Get(id); + Utils.Check(!account.Protected, "Cannot edit a protected account"); + if (account.Id != null) + Title += " - " + account.AccountName; + Record = new JObject().AddRange( + "header", account, + "AccountTypes", new Select().AccountTypes(""), + "Transactions", Database.QueryOne("SELECT idJournal FROM Journal WHERE AccountId = " + id) != null + ); + } + + /// + /// List all journals for this account + /// + public IEnumerable DetailListing(int id) { + Extended_Document last = null; + int lastId = 0; + foreach (JObject l in Database.Query(@"SELECT Journal.idJournal, Document.*, NameAddress.Name AS DocumentName, DocType, Journal.Cleared, Journal.Amount AS DocumentAmount, AccountName AS DocumentAccountName +FROM Journal +LEFT JOIN Document ON idDocument = Journal.DocumentId +LEFT JOIN DocumentType ON DocumentType.idDocumentType = Document.DocumentTypeId +LEFT JOIN NameAddress ON NameAddress.idNameAddress = Journal.NameAddressId +LEFT JOIN Journal AS J ON J.DocumentId = Journal.DocumentId AND J.AccountId <> Journal.AccountId +LEFT JOIN Account ON Account.idAccount = J.AccountId +WHERE Journal.AccountId = " + id + @" +ORDER BY DocumentDate DESC, idDocument DESC")) { + Extended_Document line = l.To(); + if (last != null) { + if (lastId == l.AsInt("idJournal")) { + last.DocumentAccountName = "-split-"; + continue; + } + yield return last; + last = null; + } + last = line; + lastId = l.AsInt("idJournal"); + } + if (last != null) + yield return last; + } + + public AjaxReturn DetailDelete(int id) { + Database.BeginTransaction(); + FullAccount account = Database.Get(id); + Utils.Check(account.idAccount == id, "Account nto found"); + Utils.Check(!account.Protected, "Cannot delete a protected account"); + Utils.Check(Database.QueryOne("SELECT idJournal FROM Journal WHERE AccountId = " + id) == null, "Cannot delete - there are transactions"); + Database.Delete("Account", id, true); + Database.Commit(); + return new AjaxReturn() { redirect = "/accounting/default.html" }; + } + + /// + /// Update an account after editing. + /// + public AjaxReturn DetailPost(Account json) { + Account existing = Database.Get(json); + Utils.Check(!existing.Protected, "Cannot edit a protected account"); + Database.BeginTransaction(); + AjaxReturn result = PostRecord(json, true); + if (string.IsNullOrEmpty(result.error) && existing.idAccount > 0 && json.AccountName != existing.AccountName) { + // This might be a parent account - if so change the name of subaccounts + foreach(Account a in Database.Query("SELECT * FROM Account WHERE AccountName LIKE " + + Database.Quote(existing.AccountName + ":%"))) { + if(a.AccountName.StartsWith(json.AccountName + ":")) { + a.AccountName = json.AccountName + a.AccountName.Substring(json.AccountName.Length); + Database.Update(a); + } + } + } + Database.Commit(); + return result; + } + + /// + /// Retrieve a General Ledger Journal by id, for editing + /// + public void Document(int id) { + Extended_Document header = getDocument(id); + if (header.idDocument == null) { + header.DocumentTypeId = (int)DocType.GeneralJournal; + header.DocType = DocType.GeneralJournal.UnCamel(); + header.DocumentDate = Utils.Today; + header.DocumentName = ""; + header.DocumentIdentifier = Settings.NextJournalNumber.ToString(); + if (GetParameters["acct"].IsInteger()) { + FullAccount acct = Database.QueryOne("*", "WHERE idAccount = " + GetParameters["acct"], "Account"); + if (acct.idAccount != null) { + header.DocumentAccountId = (int)acct.idAccount; + header.DocumentAccountName = acct.AccountName; + } + } + } else { + checkDocType(header.DocumentTypeId, DocType.GeneralJournal); + } + JObject record = new JObject().AddRange("header", header, + "detail", Database.Query("idJournal, DocumentId, JournalNum, AccountId, Memo, Amount, NameAddressId, Name", + "WHERE Journal.DocumentId = " + id + " ORDER BY JournalNum", + "Document", "Journal")); + Database.NextPreviousDocument(record, "JOIN Journal ON DocumentId = idDocument WHERE DocumentTypeId = " + (int)DocType.GeneralJournal + + (header.DocumentAccountId > 0 ? " AND AccountId = " + header.DocumentAccountId : "")); + Select s = new Select(); + record.AddRange("Accounts", s.AllAccounts(""), + "VatCodes", s.VatCode(""), + "Names", s.Name("")); + Record = record; + } + + /// + /// Delete a General Ledger Journal + /// + public AjaxReturn DocumentDelete(int id) { + return deleteDocument(id, DocType.GeneralJournal); + } + + /// + /// Update a General Ledger Journal after editing. + /// + public AjaxReturn DocumentPost(JournalDocument json) { + Database.BeginTransaction(); + Extended_Document document = json.header; + JObject oldDoc = getCompleteDocument(document.idDocument); + checkDocType(document.DocumentTypeId, DocType.GeneralJournal); + if (document.idDocument == null) + allocateDocumentIdentifier(document); + decimal total = 0, vat = 0; + int lineNum = 1; + Database.Update(document); + Settings.RegisterNumber(this, (int?)DocType.GeneralJournal, Utils.ExtractNumber(document.DocumentIdentifier)); + // Find any existing VAT record + Journal vatJournal = Database.QueryOne("SELECT * FROM Journal WHERE DocumentId = " + document.idDocument + + " AND AccountId = " + (int)Acct.VATControl + " ORDER BY JournalNum DESC"); + JournalDetail vatDetail = null; + if (vatJournal.idJournal != null) + Database.Delete("Journal", (int)vatJournal.idJournal, false); + foreach (JournalDetail detail in json.detail) { + if (detail.AccountId == 0) continue; + total += detail.Amount; + if (detail.AccountId == (int)Acct.VATControl) { + // Vat has to all be posted on the last line + vatDetail = detail; + vat += detail.Amount; + continue; + } + // Get existing journal (if any) + Journal journal = Database.Get(new Journal() { + DocumentId = (int)document.idDocument, + JournalNum = lineNum + }); + detail.Id = journal.Id; + detail.DocumentId = (int)document.idDocument; + detail.JournalNum = lineNum; + if (detail.NameAddressId == null || detail.NameAddressId == 0) { + detail.NameAddressId = string.IsNullOrWhiteSpace(detail.Name) ? + 1 : + Database.ForeignKey("NameAddress", + "Type", "O", + "Name", detail.Name); + } + // Change outstanding by the change in the amount + detail.Outstanding = journal.Outstanding + detail.Amount - journal.Amount; + Database.Update(detail); + if (lineNum > 1) { + // Create a dummy line record + Line line = new Line(); + line.idLine = detail.idJournal; + line.Qty = 0; + line.LineAmount = -detail.Amount; + line.VatCodeId = null; + line.VatRate = 0; + line.VatAmount = 0; + Database.Update(line); + } + lineNum++; + } + Utils.Check(total == 0, "Journal does not balance by {0}", total); + // Delete any lines and journals that were in the old version, but not in the new + Database.Execute("DELETE FROM Line WHERE idLine IN (SELECT idJournal FROM Journal WHERE DocumentId = " + document.idDocument + " AND JournalNum >= " + lineNum + ")"); + Database.Execute("DELETE FROM Journal WHERE DocumentId = " + document.idDocument + " AND JournalNum >= " + lineNum); + if (vat != 0 || vatJournal.idJournal != null) { + // There is, or was, a posting to vat + decimal changeInVatAmount = vat - vatJournal.Amount; + vatJournal.DocumentId = (int)document.idDocument; + vatJournal.AccountId = (int)Acct.VATControl; + if (vatDetail != null) { + if ((vatDetail.NameAddressId == null || vatDetail.NameAddressId == 0) && !string.IsNullOrWhiteSpace(vatDetail.Name)) { + vatJournal.NameAddressId = Database.ForeignKey("NameAddress", + "Type", "O", + "Name", vatDetail.Name); + } else { + vatJournal.NameAddressId = vatDetail.NameAddressId; + } + } + if(vatJournal.NameAddressId == null || vatJournal.NameAddressId == 0) + vatJournal.NameAddressId = 1; + vatJournal.Memo = "Total VAT"; + vatJournal.JournalNum = lineNum++; + vatJournal.Amount = vat; + vatJournal.Outstanding += changeInVatAmount; + Database.Update(vatJournal); + } + // Audit the change + JObject newDoc = getCompleteDocument(document.idDocument); + Database.AuditUpdate("Document", document.idDocument, oldDoc, newDoc); + Settings.RegisterNumber(this, document.DocumentTypeId, Utils.ExtractNumber(document.DocumentIdentifier)); + Database.Commit(); + return new AjaxReturn() { message = "Journal saved", id = document.idDocument }; + } + + /// + /// Retrieve a VAT return for review. + /// + /// A specific VAT return, or 0 to get one for last quarter + public void VatReturn(int id) { + // Find the VAT payment to HMRC + // It will be a cheque, credit, or credit card equivalent + // Journal line 2 will be to VAT control + // If no id provided, get the most recently posted one + Extended_Document header = Database.QueryOne(@"SELECT Extended_Document.* +FROM Extended_Document +JOIN Journal ON DocumentId = idDocument +WHERE AccountId = " + (int)Acct.VATControl + @" +AND JournalNum = 2 +AND DocumentTypeId " + Database.In(DocType.Cheque, DocType.Deposit, DocType.CreditCardCharge, DocType.CreditCardCredit) + + (id == 0 ? "" : "AND idDocument = " + id) + @" +ORDER BY idDocument DESC"); + if (header.idDocument == null) { + Utils.Check(id == 0, "VAT return " + id + " not found"); + header.DocumentNameAddressId = 1; + header.DocumentName = ""; + Account acc = Database.QueryOne("*", "WHERE idAccount = " + Settings.DefaultBankAccount, "Account"); + if (acc.idAccount != null) { + header.DocumentAccountId = (int)acc.idAccount; + header.DocumentAccountName = acc.AccountName; + } + } + // If most recent VAT return is not for this quarter, we will create a new one (later, on save) + if (id == 0 && header.DocumentDate < Settings.QuarterStart(Utils.Today)) + header.idDocument = null; + if(header.idDocument == null) + header.DocumentDate = Utils.Today; + VatReturnDocument record = getVatReturn(header.idDocument, header.DocumentDate); + if (header.idDocument == null) { + header.DocumentMemo = "VAT - FROM " + record.Start.ToString("d") + " To " + record.End.ToString("d"); + header.DocumentDate = record.Due; + } + Select sel = new Select(); + Record = new JObject().AddRange("return", record, + "payment", header, + "names", sel.Other(""), + "accounts", sel.BankAccount(""), + "otherReturns", sel.VatPayments().Reverse() + ); + } + + /// + /// Update a VAt Return after review + /// + public AjaxReturn VatReturnPost(JObject json) { + Database.BeginTransaction(); + Extended_Document header = json["payment"].To(); + Utils.Check(header.idDocument == null, "Cannot amend existing VAT return"); + // Need to go to and back from json to normalize numbers + VatReturnDocument record = getVatReturn(null, Utils.Today).ToString().JsonTo(); + VatReturnDocument r = json["return"].To(); + Utils.Check(record.ToString() == r.ToString(), + "Another user has changed the VAT data - please refresh the page to get the latest data"); + FullAccount acct = Database.Get((int)header.DocumentAccountId); + allocateDocumentIdentifier(header, acct); + fixNameAddress(header, "O"); + decimal toPay = record.ToPay; + DocType t; + switch ((AcctType)acct.AccountTypeId) { + case AcctType.Bank: + t = toPay < 0 ? DocType.Deposit : DocType.Cheque; + break; + case AcctType.CreditCard: + t = toPay < 0 ? DocType.CreditCardCredit : DocType.CreditCardCharge; + break; + default: + throw new CheckException("Account missing or invalid"); + } + header.DocumentTypeId = (int)t; + Database.Insert(header); + int nextDocid = Utils.ExtractNumber(header.DocumentIdentifier); + if (nextDocid > 0 && acct.RegisterNumber(t, nextDocid)) + Database.Update(acct); + // Flag this document as part of this VAT return + header.VatPaid = header.idDocument; + Database.Update(header); + Journal journal = new Journal(); + journal.DocumentId = (int)header.idDocument; + journal.AccountId = header.DocumentAccountId; + journal.NameAddressId = header.DocumentNameAddressId; + journal.Memo = header.DocumentMemo; + journal.JournalNum = 1; + journal.Amount = -toPay; + journal.Outstanding = -toPay; + Database.Insert(journal); + journal.idJournal = null; + journal.AccountId = (int)Acct.VATControl; + journal.JournalNum = 2; + journal.Amount = toPay; + journal.Outstanding = toPay; + Database.Insert(journal); + Line line = new Line(); + line.idLine = journal.idJournal; + line.LineAmount = toPay; + Database.Insert(line); + // Flag all documents from last quarter as part of this VAT return + Database.Execute(@"UPDATE Document +JOIN Vat_Journal ON Vat_Journal.idDocument = Document.idDocument +SET Document.VatPaid = " + header.idDocument + @" +WHERE Document.VatPaid IS NULL +AND Document.DocumentDate < " + Database.Quote(Settings.QuarterStart(Utils.Today))); + JObject newDoc = getCompleteDocument(header.idDocument); + Database.AuditUpdate("Document", header.idDocument, null, newDoc); + Settings.RegisterNumber(this, header.DocumentTypeId, Utils.ExtractNumber(header.DocumentIdentifier)); + Database.Commit(); + return new AjaxReturn() { message = "Vat paid", id = header.idDocument }; + } + + /// + /// Get VAT return data for a specific VAT return (id != null) or the last quarter ending before date (id == null) + /// + VatReturnDocument getVatReturn(int? id, DateTime date) { + VatReturnDocument record = new VatReturnDocument(); + DateTime qe = Settings.QuarterStart(date); + record.Start = qe.AddMonths(-3); + record.End = qe.AddDays(-1); + record.Due = qe.AddMonths(1).AddDays(-1); + foreach (JObject r in Database.Query(@"SELECT VatType, SUM(VatAmount) AS Vat, SUM(LineAmount) AS Net +FROM Vat_Journal +JOIN VatCode ON idVatCode = VatCodeId +WHERE " + (id == null ? "VatPaid IS NULL AND DocumentDate < " + Database.Quote(qe) : "VatPaid = " + id) + @" +GROUP BY VatType")) { + switch (r.AsInt("VatType")) { + case -1: + record.Sales = r.ToObject(); + break; + case 1: + record.Purchases = r.ToObject(); + break; + default: + throw new CheckException("Invalid VatType {0}", r["VatType"]); + } + } + return record; + } + + public class JournalDetail : Journal { + public string Name; + } + + public class JournalDocument : JsonObject { + public Extended_Document header; + public List detail; + } + + public class VatReturnLine { + /// + /// -1 for Sales, 1 for Purchases + /// + public int VatType; + public decimal Vat; + public decimal Net; + } + + public class VatReturnDocument : JsonObject { + public VatReturnDocument() { + Sales = new VatReturnLine() { VatType = -1 }; + Purchases = new VatReturnLine() { VatType = 1 }; + } + public VatReturnLine Sales; + public VatReturnLine Purchases; + public DateTime Start; + public DateTime End; + public DateTime Due; + public decimal ToPay { + get { + return Sales.Vat - Purchases.Vat; + } + } + } + + } +} diff --git a/AppModule.cs b/AppModule.cs new file mode 100644 index 0000000..324ced4 --- /dev/null +++ b/AppModule.cs @@ -0,0 +1,1061 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Net; +using System.Net.Http; +using System.Web; +using System.IO; +using System.Reflection; +using System.Threading; +using Mustache; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Holds current session information + /// + public class Session : WebServer.BaseSession { + public Session(WebServer server) + : base(server) { + } + } + + /// + /// Base class for all app modules + /// + + public class AppModule : IDisposable { + static Dictionary _appModules; // List of all AppModule types by name ("Module" stripped off end) + static int _lastJob; // Last batch job + static Dictionary _jobs = new Dictionary(); + protected static Settings _settings; // Common settings, read from database + + static AppModule() { + // Build the _appModules dictionary + var baseType = typeof(AppModule); + var assembly = baseType.Assembly; + _appModules = new Dictionary(); + foreach (Type t in assembly.GetTypes().Where(t => t != baseType && t.IsSubclassOf(baseType))) { + string name = t.Name; + if (name.EndsWith("Module")) + name = name.Substring(0, name.Length - 6); + _appModules[name.ToLower()] = t; + } + using (Database db = new Database()) { + _settings = db.QueryOne("SELECT * FROM Settings"); + } + } + + public static Encoding Encoding = Encoding.GetEncoding(1252); + public static string Charset = "ANSI"; + + Database _db; + + public void CloseDatabase() { + if (_db != null) { + _db.Dispose(); + _db = null; + } + } + + public Database Database { + get { + if (_db == null) { + _db = new Database(); + _db.Logging = (LogLevel)_settings.DatabaseLogging; + } + return _db; + } + } + + /// + /// Get the AppModule for a module name from the url + /// + static public Type GetModule(string name) { + name = name.ToLower(); + return _appModules.ContainsKey(name) ? _appModules[name] : null; + } + + /// + /// So templates can access Session + /// + [JsonIgnore] + public Session Session; + + /// + /// Session data in dynamic form + /// + [JsonIgnore] + public dynamic SessionData { + get { return Session.Object; } + } + + public StringBuilder LogString; + + public void Log(string s) { + if (LogString != null) LogString.AppendLine(s); + } + + public void Log(string format, params object[] args) { + if (LogString != null) LogString.AppendFormat(format + "\r\n", args); + } + + public void Dispose() { + if (_db != null && Batch == null) { + CloseDatabase(); + } + } + + public HttpListenerContext Context; + + public Exception Exception; + + /// + /// Module menu - line 2 of page top menu + /// + public MenuOption[] Menu; + + /// + /// Alert message to show user + /// + public string Message; + + public string Method; + + public string Module; + + public string OriginalMethod; + + public string OriginalModule; + + /// + /// Parameters from Url + /// + public NameValueCollection GetParameters; + + /// + /// Get & Post parameters combined + /// + public JObject Parameters = new JObject(); + + /// + /// Parameters from POST + /// + public JObject PostParameters; + + public HttpListenerRequest Request { + get { return Context.Request; } + } + + public HttpListenerResponse Response { + get { return Context.Response; } + } + + /// + /// Used for the web page title + /// + public string Title; + + public Settings Settings { + get { return _settings; } + } + + public AppSettings Config { + get { return AccountServer.AppSettings.Default; } + } + + static public Settings AppSettings { + get { return _settings; } + } + + /// + /// Goes into the web page header + /// + public string Head; + + /// + /// Goes into the web page body + /// + public string Body; + + public bool ResponseSent { get; private set; } + + public string Today { + get { return Utils.Today.ToString("yyyy-MM-dd"); } + } + + /// + /// Generic object for templates to use - usually contains data from the database + /// + public object Record; + + /// + /// Background batch job (e.g. import, restore) + /// + public class BatchJob { + AppModule _module; + string _redirect; + int _record; + + /// + /// Create a batch job that redirects back to the module's original method on completion + /// + /// Module containing Database, Session, etc. + /// Action to run the job + public BatchJob(AppModule module, Action action) + : this(module, null, action) { + } + + /// + /// Create a batch job that redirects somewhere specific + /// + /// Module containing Database, Session, etc. + /// Action to run the job + public BatchJob(AppModule module, string redirect, Action action) { + _module = module; + // Get the next job number + lock (_jobs) { + Id = ++_lastJob; + _jobs[Id] = module; + } + _redirect = redirect ?? "/" + module.Module.ToLower() + "/" + module.Method.ToLower() + ".html"; + Status = ""; + Records = 100; + module.Log("Started batch job {0}", Id); + new Thread(new ThreadStart(delegate() { + try { + action(); + } catch (Exception ex) { + WebServer.Log("Batch job {0} Exception: {1}", Id, ex); + Status = "An error occurred"; + Error = ex.Message; + } + WebServer.Log("Finished batch job {0}", Id); + Finished = true; + module.CloseDatabase(); + Thread.Sleep(60000); // 1 minute delay in case of calls to get status + lock (_jobs) { + _jobs.Remove(Id); + } + })) { + IsBackground = true, + Name = this.GetType().Name + }.Start(); + module.Batch = this; + module.Module = "admin"; + module.Method = "batch"; + } + + /// + /// Job id + /// + public int Id { get; private set; } + + /// + /// Error message (e.g. on exception) + /// + public string Error; + + public bool Finished; + + /// + /// For progress display + /// + public int PercentComplete { + get { + return Records == 0 ? 100 : 100 * Record / Records; + } + } + + /// + /// To indicate progress (0...Records) + /// + public virtual int Record { + get { + return _record; + } + set { + _record = value; + } + } + + /// + /// Total number of records (for progress bar) + /// + public int Records; + + /// + /// Where redirecting to on completion + /// + public string Redirect { + get { + return _redirect == null ? null : _redirect + (_redirect.Contains('?') ? '&' : '?') + "message=" + + (string.IsNullOrEmpty(_module.Message) ? "Job completed" : HttpUtility.UrlEncode(_module.Message)); + } + } + + /// + /// For status/progress display + /// + public string Status; + } + + /// + /// BatchJob started by this module + /// + public BatchJob Batch; + + /// + /// Get batch job from id (for status/progress display) + /// + public static AppModule GetBatchJob(int id) { + AppModule job; + return _jobs.TryGetValue(id, out job) ? job : null; + } + + /// + /// Responds to a Url request. Set up the AppModule variables and call the given method + /// + + public void Call(HttpListenerContext context, string moduleName, string methodName) { + Context = context; + OriginalModule = Module = moduleName.ToLower(); + OriginalMethod = Method = (methodName ?? "default").ToLower(); + LogString.Append(GetType().Name + ":" + Title + ":"); + // Collect get parameters + GetParameters = new NameValueCollection(); + for (int i = 0; i < Request.QueryString.Count; i++) { + string key = Request.QueryString.GetKey(i); + string value = Request.QueryString[i]; + if (key == null) { + GetParameters[value] = ""; + } else { + GetParameters[key] = value; + if (key == "message") + Message = value; + } + } + // Add into parameters array + Parameters.AddRange(GetParameters); + // Collect POST parameters + if (context.Request.HttpMethod == "POST") { + PostParameters = new JObject(); + if (context.Request.ContentType != null) { + string data; + // Encoding 1252 will give exactly 1 character per input character, without translation + using (StreamReader s = new StreamReader(context.Request.InputStream, Encoding.GetEncoding(1252))) { + data = s.ReadToEnd(); + } + if (context.Request.ContentType.StartsWith("multipart/form-data")) { + string boundary = "--" + (Regex.Split(context.Request.ContentType, "boundary=")[1]); + foreach (string part in Regex.Split("\r\n" + data, ".." + boundary, RegexOptions.Singleline)) { + if (part.Trim() == "" || part.Trim() == "--") continue; + int pos = part.IndexOf("\r\n\r\n"); + string headers = part.Substring(0, pos); + string value = part.Substring(pos + 4); + Match match = new Regex(@"form-data; name=""(\w+)""").Match(headers); + if (match.Success) { + // This is a file upload + string field = match.Groups[1].Value; + match = new Regex(@"; filename=""(.*)""").Match(headers); + if (match.Success) { + PostParameters.Add(field, new UploadedFile(Path.GetFileName(match.Groups[1].Value), value).ToJToken()); + } else { + PostParameters.Add(field, value); + } + } + } + } else { + PostParameters.AddRange(HttpUtility.ParseQueryString(data)); + } + Parameters.AddRange(PostParameters); + } + } + MethodInfo method = null; + try { + object o = CallMethod(out method); + if (method == null) { + WriteResponse("Page /" + Module + "/" + Method + ".html not found", "text/html", HttpStatusCode.NotFound); + return; + } + if (!ResponseSent) { + // Method has not sent a response - do the default response + Response.AddHeader("Expires", DateTime.UtcNow.ToString("R")); + if (method.ReturnType == typeof(void)) + Respond(); // Builds response from template + else + WriteResponse(o, null, HttpStatusCode.OK); // Builds response from return value + } + } catch (Exception ex) { + Log("Exception: {0}", ex); + if (ex is DatabaseException) + Log(((DatabaseException)ex).Sql); // Log Sql of all database exceptions + if (method == null || method.ReturnType == typeof(void)) throw; // Will produce exception page + while (ex is TargetInvocationException) { + // Strip off TargetInvokationExceptions so message is meaningful + ex = ex.InnerException; + } + // Send an AjaxReturn object indicating the error + WriteResponse(new AjaxReturn() { error = ex.Message }, null, HttpStatusCode.OK); + } + } + + /// + /// Call the method named by Method, and return its result + /// + /// Also return the MethodInfo so caller knows what return type it has. + /// Will be set to null if there is no such named method. + + public object CallMethod(out MethodInfo method) { + List parms = new List(); + method = this.GetType().GetMethod(Method, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); + if (method == null) { + return null; + } + string moduleName = GetType().Name; + if (moduleName.EndsWith("Module")) + moduleName = moduleName.Substring(0, moduleName.Length - 6); + Title = moduleName.UnCamel(); + if (method.Name != "Default") Title += " - " + method.Name.UnCamel(); + // Collect any parameters required by the method from the GET/POST parameters + foreach (ParameterInfo p in method.GetParameters()) { + JToken val = Parameters[p.Name]; + object o; + Utils.Check(val != null, "Missing parameter {0}", p.Name); + try { + if (p.ParameterType == typeof(int) + || p.ParameterType == typeof(decimal) + || p.ParameterType == typeof(string) + || p.ParameterType == typeof(DateTime)) { + o = val.ToObject(p.ParameterType); + } else if (p.ParameterType == typeof(UploadedFile)) { + if (val.ToString() == "null") + o = null; + else + o = val.ToObject(typeof(UploadedFile)); + } else if (val.Type == JTokenType.String && val.ToString() == "null") { + o = null; // "null" means null + } else if (p.ParameterType == typeof(int?) + || p.ParameterType == typeof(decimal?)) { + o = val.ToObject(p.ParameterType); + } else { + o = val.ToObject().JsonTo(p.ParameterType); + } + parms.Add(o); + } catch(Exception ex) { + Match m = Regex.Match(ex.Message, "Error converting value (.*) to type '(.*)'. Path '(.*)', line"); + if(m.Success) + throw new CheckException(ex, "{0} is an invalid value for {1}", m.Groups[1], m.Groups[3]); + throw new CheckException(ex, "Could not convert {0} to {1}", val, p.ParameterType.Name); + } + } + return method.Invoke(this, parms.Count == 0 ? null : parms.ToArray()); + } + + /// + /// Method to call if no method supplied in url + /// + public virtual void Default() { + } + + /// + /// Get the last document of the given type with NameAddressId == id + /// + + public object DocumentLast(int id, DocType type) { + JObject result = new JObject(); + Extended_Document header = Database.QueryOne("SELECT * FROM Extended_Document WHERE DocumentTypeId = " + (int)type + + " AND DocumentNameAddressId = " + id + + " ORDER BY DocumentDate DESC, idDocument DESC"); + if (header.idDocument != null) { + if (Utils.ExtractNumber(header.DocumentIdentifier) > 0) + header.DocumentIdentifier = ""; + result.AddRange("header", header, + "detail", Database.Query("idJournal, DocumentId, Line.VatCodeId, VatRate, JournalNum, Journal.AccountId, Memo, LineAmount, VatAmount", + "WHERE Journal.DocumentId = " + header.idDocument + " AND idLine IS NOT NULL ORDER BY JournalNum", + "Document", "Journal", "Line")); + } + return result; + } + + /// + /// Allocate the next unused cheque number/deposit number/etc. + /// + protected void allocateDocumentIdentifier(Extended_Document document) { + if ((document.idDocument == null || document.idDocument == 0) && document.DocumentIdentifier == "") { + DocType type = (DocType)document.DocumentTypeId; + int nextDocId = 0; + switch (type) { + case DocType.Invoice: + case DocType.Payment: + case DocType.CreditMemo: + case DocType.Bill: + case DocType.BillPayment: + case DocType.Credit: + case DocType.GeneralJournal: + nextDocId = Settings.NextNumber(type); + break; + case DocType.Cheque: + case DocType.Deposit: + case DocType.CreditCardCharge: + case DocType.CreditCardCredit: + FullAccount acct = Database.QueryOne("*", "WHERE idAccount = " + document.DocumentAccountId, "Account"); + nextDocId = acct.NextNumber(type); + break; + } + document.DocumentIdentifier = nextDocId != 0 ? nextDocId.ToString() : ""; + } + } + + /// + /// Allocate the next unused cheque number/deposit number/etc. + /// + protected void allocateDocumentIdentifier(Extended_Document document, FullAccount acct) { + if ((document.idDocument == null || document.idDocument == 0) && document.DocumentIdentifier == "") { + DocType type = (DocType)document.DocumentTypeId; + int nextDocId = 0; + switch (type) { + case DocType.Invoice: + case DocType.Payment: + case DocType.CreditMemo: + case DocType.Bill: + case DocType.BillPayment: + case DocType.Credit: + case DocType.GeneralJournal: + nextDocId = Settings.NextNumber(type); + break; + case DocType.Cheque: + case DocType.Deposit: + case DocType.CreditCardCharge: + case DocType.CreditCardCredit: + nextDocId = acct.NextNumber(type); + break; + } + document.DocumentIdentifier = nextDocId != 0 ? nextDocId.ToString() : ""; + } + } + + /// + /// Check AcctType type is one of the supplied account tyes + /// + protected AcctType checkAcctType(int? type, params AcctType[] allowed) { + Utils.Check(type != null, "Account Type missing"); + AcctType t = (AcctType)type; + Utils.Check(Array.IndexOf(allowed, t) >= 0, "Cannot use this screen to edit {0}s", t.UnCamel()); + return t; + } + + /// + /// Check AcctType type is one of the supplied account tyes + /// + protected AcctType checkAcctType(JToken type, params AcctType[] allowed) { + return checkAcctType(type.To(), allowed); + } + + /// + /// Check type of supplied account is one of the supplied account tyes + /// + protected AcctType checkAccountIsAcctType(int? account, params AcctType[] allowed) { + Utils.Check(account != null, "Account missing"); + Account a = Database.Get((int)account); + return checkAcctType(a.AccountTypeId, allowed); + } + + /// + /// Check type is one of the supplied document types + /// + protected DocType checkDocType(int? type, params DocType[] allowed) { + Utils.Check(type != null, "Document Type missing"); + DocType t = (DocType)type; + Utils.Check(Array.IndexOf(allowed, t) >= 0, "Cannot use this screen to edit {0}s", t.UnCamel()); + return t; + } + + /// + /// Check type is one of the supplied document types + /// + protected DocType checkDocType(JToken type, params DocType[] allowed) { + return checkDocType(type.To(), allowed); + } + + /// + /// Check type is the supplied name type ("C" for customer, "S" for supplier, "O" for other) + /// + protected void checkNameType(string type, string allowed) { + Utils.Check(type == allowed, "Name is not a {0}", allowed.NameType()); + } + + /// + /// Check NameAddress record is the supplied name type ("C" for customer, "S" for supplier, "O" for other) + /// + protected void checkNameType(int? id, string allowed) { + Utils.Check(id != null, allowed.NameType() + " missing"); + NameAddress n = Database.Get((int)id); + checkNameType(n.Type, allowed); + } + + /// + /// Check NameAddress record is the supplied name type ("C" for customer, "S" for supplier, "O" for other) + /// + protected void checkNameType(JToken id, string allowed) { + checkNameType(id.To(), allowed); + } + + /// + /// Delete a document, first checking it is one of the supplied types + /// + protected AjaxReturn deleteDocument(int id, params DocType[] allowed) { + AjaxReturn result = new AjaxReturn(); + Database.BeginTransaction(); + Extended_Document record = getDocument(id); + Utils.Check(record != null && record.idDocument != null, "Record does not exist"); + DocType type = checkDocType(record.DocumentTypeId, allowed); + if (record.DocumentOutstanding != record.DocumentAmount) { + result.error = type.UnCamel() + " has been " + + (type == DocType.Payment || type == DocType.BillPayment ? "used to pay or part pay invoices" : "paid or part paid") + + " it cannot be deleted"; + } else if(record.VatPaid != null) { + result.error = "VAT has been declared on " + type.UnCamel() + " it cannot be deleted"; + } else { + Database.Audit(AuditType.Delete, "Document", id, getCompleteDocument(record)); + Database.Execute("DELETE FROM StockTransaction WHERE idStockTransaction IN (SELECT idJournal FROM Journal WHERE DocumentId = " + id + ")"); + Database.Execute("DELETE FROM Line WHERE idLine IN (SELECT idJournal FROM Journal WHERE DocumentId = " + id + ")"); + Database.Execute("DELETE FROM Journal WHERE DocumentId = " + id); + Database.Execute("DELETE FROM Document WHERE idDocument = " + id); + Database.Commit(); + result.message = type.UnCamel() + " deleted"; + } + return result; + } + + protected void fixNameAddress(Extended_Document document, string nameType) { + if (document.DocumentNameAddressId == null || document.DocumentNameAddressId == 0) { + document.DocumentNameAddressId = string.IsNullOrWhiteSpace(document.DocumentAddress) ? 1 : + Database.ForeignKey("NameAddress", + "Type", nameType, + "Name", document.DocumentName, + "Address", document.DocumentAddress); + } else { + checkNameType(document.DocumentNameAddressId, nameType); + } + } + + /// + /// Get a complete document (header and details) by id + /// + protected JObject getCompleteDocument(int? id) { + Extended_Document doc = getDocument(id); + if (doc.idDocument == null) return null; + return getCompleteDocument(doc); + } + + /// + /// Get a complete document (including details) from the supplied document header + /// + protected JObject getCompleteDocument(T document) where T : Extended_Document { + return new JObject().AddRange("header", document, + "detail", Database.Query(@"SELECT Journal.*, AccountName, Name, Qty, ProductId, ProductName, LineAmount, Line.VatCodeId, Code, VatRate, VatAmount +FROM Journal +LEFT JOIN Line ON idLine = idJournal +LEFT JOIN Account ON idAccount = Journal.AccountId +LEFT JOIN NameAddress ON idNameAddress = NameAddressId +LEFT JOIN Product ON idProduct = ProductId +LEFT JOIN VatCode ON idVatCode = Line.VatCodeId +WHERE Journal.DocumentId = " + document.idDocument)); + } + + /// + /// Read the current copy of the supplied document from the database + /// + protected T getDocument(T document) where T : JsonObject { + if (document.Id == null) return Database.EmptyRecord(); + return getDocument((int)document.Id); + } + + /// + /// Read the current copy of the supplied document id from the database + /// + protected T getDocument(int? id) where T : JsonObject { + return Database.QueryOne("SELECT * FROM Extended_Document WHERE idDocument = " + (id == null ? "NULL" : id.ToString())); + } + + /// + /// Add an extra option to the menu, before any "New xxxx" options + /// + protected void insertMenuOption(MenuOption o) { + int i; + for (i = 0; i < Menu.Length; i++) { + if (Menu[i].Text.StartsWith("New ")) + break; + } + List list = Menu.ToList(); + list.Insert(i, o); + Menu = list.ToArray(); + } + + /// + /// Load the text of a template from a file, first processing any includes, + /// and making any required substitutions + /// + string loadFile(string filename) { + using (StreamReader s = Utils.FileInfoForUrl(filename.ToLower()).OpenText()) { + string text = s.ReadToEnd(); + // Process includes with a recursive call + text = Regex.Replace(text, @"\{\{ *include +(.*) *\}\}", delegate(Match m) { + return loadFile(m.Groups[1].Value); + }); + // In javascript, you can comment Mustache parameters to avoid syntax errors. The comment "//" is removed + text = Regex.Replace(text, @"//[\s]*{{([^{}]+)}}[\s]*$", "{{$1}}"); + // In javascript, you can place a Mustache parameter in a string with a pling ('!{{name}}') to avoid syntax errors. + // The quotes and the pling are removed + text = Regex.Replace(text, @"'!{{([^{}]+)}}'", "{{$1}}"); + // {{{name}}} is replaced by html quoted version of the parameter value - the quoting is done later + // we just mark it with \001 and \002 characters for now. + text = Regex.Replace(text, @"{{{([^{}]+)}}}", "\001{{$1}}\002"); + return text; + } + } + + /// + /// Load the named template, render using Mustache to substite the parameters from the supplied object. + /// E.g. {{Body} in the template will be replaced with the obj.Body.ToString() + /// + public string LoadTemplate(string filename, object obj) { + try { + FormatCompiler compiler = new FormatCompiler(); + compiler.RemoveNewLines = false; + if (Path.GetExtension(filename) == "") + filename += ".html"; + Generator generator = compiler.Compile(loadFile(filename)); + string result = generator.Render(obj); + result = Regex.Replace(result, "\001(.*?)\002", delegate(Match m) { + return HttpUtility.HtmlEncode(m.Groups[1].Value).Replace("\n", "\n
"); + }, RegexOptions.Singleline); + return result; + } catch (DatabaseException) { + throw; + } catch (Exception ex) { + throw new CheckException(ex, "{0}.html:{1}", filename, ex.Message); + } + } + + /// + /// Load the named template, and render using Mustache from the supplied object. + /// E.g. {{Body} in the template will be replaced with the obj.Body.ToString() + /// Then split into (goes to this.Head) and (goes to this.Body) + /// If no head/body sections, the whole template goes into this.Body. + /// Then render the default template from this. + /// + public string Template(string filename, object obj) { + string body = LoadTemplate(filename, obj); + Match m = Regex.Match(body, @"(.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (m.Success) { + this.Head = m.Groups[1].Value; + body = body.Replace(m.Value, ""); + } else { + this.Head = ""; + } + m = Regex.Match(body, @"(.*)", RegexOptions.IgnoreCase | RegexOptions.Singleline); + this.Body = m.Success ? m.Groups[1].Value : body; + return LoadTemplate("default", this); + } + + public void Redirect(string url) { + if (Context == null) + return; + Response.Redirect(url); + WriteResponse("", "text/plain", HttpStatusCode.Redirect); + } + + /// + /// Render the template Module/Method.html from this. + /// + public void Respond() { + try { + string filename = Path.Combine(Module, Method).ToLower(); + string page = Template(filename, this); + WriteResponse(page, "text/html", HttpStatusCode.OK); + } catch (System.IO.FileNotFoundException ex) { + Log(ex.ToString()); + Exception = ex; + WriteResponse(Template("exception", this), "text/html", HttpStatusCode.NotFound); + } catch (Exception ex) { + Log(ex.ToString()); + Exception = ex; + WriteResponse(Template("exception", this), "text/html", HttpStatusCode.InternalServerError); + } + } + + /// + /// Return the sign to use for documents of the supplied type. + /// + /// -1 or 1 + static public int SignFor(DocType docType) { + switch (docType) { + case DocType.Invoice: + case DocType.Payment: + case DocType.Credit: + case DocType.Deposit: + case DocType.CreditCardCredit: + case DocType.GeneralJournal: + case DocType.Sell: + return -1; + default: + return 1; + } + } + + /// + /// Save an arbitrary JObject to the database, optionally also saving an audit trail + /// + public AjaxReturn PostRecord(JsonObject record, bool audit) { + AjaxReturn retval = new AjaxReturn(); + try { + Database.Update(record, audit); + retval.id = record.Id; + } catch (Exception ex) { + Message = ex.Message; + retval.error = ex.Message; + } + return retval; + } + + /// + /// Write the response to an Http request. + /// + /// The object to write ("Operation complete" if null) + /// The content type (suitable default is used if null) + /// The Http return code + public void WriteResponse(object o, string contentType, HttpStatusCode status) { + if (ResponseSent) throw new CheckException("Response already sent"); + ResponseSent = true; + Response.StatusCode = (int)status; + Response.ContentEncoding = Encoding; + switch (contentType) { + case "text/plain": + case "text/html": + contentType += ";charset=" + Charset; + break; + } + string logStatus = status.ToString(); + byte[] msg; + if (o != null) { + if (o is Stream) { + // Stream is sent unchanged + Response.ContentType = contentType ?? "application/binary"; + Response.ContentLength64 = ((Stream)o).Length; + Log("{0}:{1} bytes ", status, Response.ContentLength64); + using (Stream r = Response.OutputStream) { + ((Stream)o).CopyTo(r); + } + return; + } else if (o is string) { + // String is sent unchanged + msg = Encoding.GetBytes((string)o); + Response.ContentType = contentType ?? "text/plain;charset=" + Charset; + } else { + // Anything else is sent as json + Response.ContentType = contentType ?? "application/json;charset=" + Charset; + msg = Encoding.GetBytes(o.ToJson()); + if (o is AjaxReturn) + logStatus = o.ToString(); + } + } else { + msg = Encoding.GetBytes("Operation complete"); + Response.ContentType = contentType ?? "text/plain;charset=" + Charset; + } + Response.ContentLength64 = msg.Length; + Log("{0}:{1} bytes ", logStatus, Response.ContentLength64); + using (Stream r = Response.OutputStream) { + r.Write(msg, 0, msg.Length); + } + } + + } + + /// + /// Class to serve files + /// + public class FileSender : AppModule { + string _filename; + + public FileSender(string filename) { + _filename = filename; + } + + public override void Default() { + Title = ""; + FileInfo file = Utils.FileInfoForUrl(_filename); + if (!file.Exists) { + WriteResponse("", "text/plain", HttpStatusCode.NotFound); + return; + } + string ifModifiedSince = Request.Headers["If-Modified-Since"]; + if (!string.IsNullOrEmpty(ifModifiedSince)) { + try { + DateTime modifiedSince = DateTime.Parse(ifModifiedSince.Split(';')[0]); + if (modifiedSince >= file.LastWriteTimeUtc) { + WriteResponse("", "text/plain", HttpStatusCode.NotModified); + return; + } + } catch { + } + } + using (Stream i = file.OpenRead()) { + string contentType; + switch (Path.GetExtension(_filename).ToLower()) { + case ".htm": + case ".html": + contentType = "text/html"; + break; + case ".css": + contentType = "text/css"; + break; + case ".js": + contentType = "text/javascript"; + break; + case ".xml": + contentType = "text/xml"; + break; + case ".bmp": + contentType = "image/bmp"; + break; + case ".gif": + contentType = "image/gif"; + break; + case ".jpg": + contentType = "image/jpeg"; + break; + case ".jpeg": + contentType = "image/jpeg"; + break; + case ".png": + contentType = "image/x-png"; + break; + case ".txt": + contentType = "text/plain"; + break; + case ".doc": + contentType = "application/msword"; + break; + case ".pdf": + contentType = "application/pdf"; + break; + case ".xls": + contentType = "application/x-msexcel"; + break; + case ".wav": + contentType = "audio/x-wav"; + break; + default: + contentType = "application/binary"; + break; + } + Response.AddHeader("Last-Modified", file.LastWriteTimeUtc.ToString("r")); + WriteResponse(i, contentType, HttpStatusCode.OK); + } + } + } + + /// + /// Class to hold details of an uploaded file (from an ) + /// + public class UploadedFile { + + public UploadedFile(string name, string content) { + Name = name; + Content = content; + } + + /// + /// File contents - Windows1252 was used to read it in, so saving it as Windows1252 will be an exact binary copy + /// + public string Content { get; private set; } + + public string Name { get; private set; } + + /// + /// The file contents as a stream + /// + public Stream Stream() { + return new MemoryStream(Encoding.GetEncoding(1252).GetBytes(Content)); + } + } + + /// + /// Menu option for the second level menu + /// + public class MenuOption { + public MenuOption(string text, string url) : this(text, url, true) { + } + + public MenuOption(string text, string url, bool enabled) { + Text = text; + Url = url; + Enabled = enabled; + } + + public bool Disabled { + get { return !Enabled; } + } + + public bool Enabled; + + /// + /// Html element id - text with no spaces + /// + public string Id { + get { return Text.Replace(" ", ""); } + } + + public string Text; + + public string Url; + } + + /// + /// Generic return type used for Ajax requests + /// + public class AjaxReturn { + /// + /// Exception message - if not null or empty, request has failed + /// + public string error; + /// + /// Message for user + /// + public string message; + /// + /// Where to redirect to on completion + /// + public string redirect; + /// + /// Ask the user to confirm something, and resubmit with confirm parameter if the user says yes + /// + public string confirm; + /// + /// If a record has been saved, this is the id of the record. + /// Usually used to re-read the page, especially when the request was to create a new record. + /// + public int? id; + /// + /// Arbitrary data which the caller needs + /// + public object data; + + public override string ToString() { + StringBuilder b = new StringBuilder("AjaxReturn"); + if (!string.IsNullOrEmpty(error)) b.AppendFormat(",error:{0}", error); + if (!string.IsNullOrEmpty(message)) b.AppendFormat(",message:{0}", message); + if (!string.IsNullOrEmpty(confirm)) b.AppendFormat(",confirm:{0}", confirm); + if (!string.IsNullOrEmpty(redirect)) b.AppendFormat(",redirect:{0}", redirect); + return b.ToString(); + } + } + +} diff --git a/Banking.cs b/Banking.cs new file mode 100644 index 0000000..a9963f6 --- /dev/null +++ b/Banking.cs @@ -0,0 +1,884 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Banking module has some functionality in common with Banking (e.g. NameAddress maintenance) + /// + public class Banking : BankingAccounting { + + public Banking() { + Menu = new MenuOption[] { + new MenuOption("Listing", "/banking/default.html"), + new MenuOption("Names", "/banking/names.html"), + new MenuOption("New Account", "/banking/detail.html?id=0") + }; + } + + /// + /// List all bank accounts and credit cards + /// + /// + public object DefaultListing() { + return Database.Query("Account.*, AcctType, SUM(Amount) AS Balance", + "WHERE AccountTypeId " + Database.In(AcctType.Bank, AcctType.CreditCard) + " GROUP BY idAccount ORDER BY AccountName", + "Account", "Journal"); + } + + /// + /// Retrieve a bank/credit card account for editing + /// + public void Detail(int id) { + BankingDetail record = Database.QueryOne("Account.*, AcctType, SUM(Amount) AS Balance", + "WHERE idAccount = " + id, + "Account", "Journal"); + // Subtract future transactions to get current balance + record.CurrentBalance = record.Balance - Database.QueryOne("SELECT SUM(Amount) AS Future FROM Journal JOIN Document ON idDocument = DocumentId WHERE AccountId = " + + id + " AND DocumentDate > " + Database.Quote(Utils.Today)).AsDecimal("Future"); + if (record.Id != null) { + checkAcctType(record.AccountTypeId, AcctType.Bank, AcctType.CreditCard); + Title += " - " + record.AccountName; + } + Record = record; + } + + /// + /// List all the journals that post to this account, along with document info and the balance after the posting. + /// Detects documents which are splits (i.e. have more than 2 journals) and sets the DocumentAccountName to "-split-" + /// + IEnumerable detailsWithBalance(int id) { + JObject last = null; // previous document + int lastId = 0; // Id of previous journal + decimal balance = 0; // Running total balance + // Query gets all journals to this account, joined to document header + // Then joins to any other journals for this document, so documents with only 2 journals + // will appear once, but if there are more than 2 journals the document will appear more times + foreach (JObject l in Database.Query(@"SELECT Journal.idJournal, Document.*, NameAddress.Name As DocumentName, DocType, Journal.Cleared AS Clr, Journal.Amount As DocumentAmount, AccountName As DocumentAccountName +FROM Journal +LEFT JOIN Document ON idDocument = Journal.DocumentId +LEFT JOIN DocumentType ON DocumentType.idDocumentType = Document.DocumentTypeId +LEFT JOIN NameAddress ON NameAddress.idNameAddress = Journal.NameAddressId +LEFT JOIN Journal AS J ON J.DocumentId = Journal.DocumentId AND J.AccountId <> Journal.AccountId +LEFT JOIN Account ON Account.idAccount = J.AccountId +WHERE Journal.AccountId = " + id + @" +ORDER BY DocumentDate, idDocument")) { + if (last != null) { + if (lastId == l.AsInt("idJournal")) { + // More than 1 line in this document + last["DocumentAccountName"] = "-split-"; + // Only emit each journal to this account once + continue; + } + balance += last.AsDecimal("DocumentAmount"); + last["Balance"] = balance; + yield return last; + } + last = l; + lastId = l.AsInt("idJournal"); + } + if (last != null) { + balance += last.AsDecimal("DocumentAmount"); + last["Balance"] = balance; + yield return last; + } + } + + /// + /// Journal listing for an account + /// + public IEnumerable DetailListing(int id) { + return detailsWithBalance(id).Reverse(); + } + + /// + /// Update account details after editing + /// + public AjaxReturn DetailPost(Account json) { + checkAcctType(json.AccountTypeId, AcctType.Bank, AcctType.CreditCard); + return PostRecord(json, true); + } + + /// + /// Get a specific document (or a filled in new document) for this account + /// + internal JObject GetDocument(int id, DocType type) { + Extended_Document header = getDocument(id); + if (header.idDocument == null) { + header.DocumentTypeId = (int)type; + header.DocType = type.UnCamel(); + header.DocumentDate = Utils.Today; + header.DocumentName = ""; + if (GetParameters["acct"].IsInteger()) { + FullAccount acct = Database.QueryOne("*", "WHERE idAccount = " + GetParameters["acct"], "Account"); + if (acct.idAccount != null) { + header.DocumentAccountId = (int)acct.idAccount; + header.DocumentAccountName = acct.AccountName; + } + } + } else { + checkDocType(header.DocumentTypeId, DocType.Cheque, DocType.Deposit, DocType.CreditCardCharge, DocType.CreditCardCredit); + } + return new JObject().AddRange("header", header, + "detail", Database.Query("idJournal, DocumentId, Line.VatCodeId, VatRate, JournalNum, Journal.AccountId, Memo, LineAmount, VatAmount", + "WHERE Journal.DocumentId = " + id + " AND idLine IS NOT NULL ORDER BY JournalNum", + "Document", "Journal", "Line")); + } + + /// + /// Get a document for editing + /// + public void Document(int id, DocType type) { + Title = Title.Replace("Document", type.UnCamel()); + JObject record = GetDocument(id, type); + dynamic header = ((dynamic)record).header; + Database.NextPreviousDocument(record, "JOIN Journal ON DocumentId = idDocument WHERE DocumentTypeId = " + (int)type + + (header.DocumentAccountId > 0 ? " AND AccountId = " + header.DocumentAccountId : "")); + Select s = new Select(); + record.AddRange("Accounts", s.Account(""), + "VatCodes", s.VatCode(""), + "Names", s.Other("")); + Record = record; + } + + public AjaxReturn DocumentDelete(int id) { + return deleteDocument(id, DocType.Cheque, DocType.Deposit, DocType.CreditCardCharge, DocType.CreditCardCredit); + } + + /// + /// Update a document after editing + /// + public AjaxReturn DocumentPost(BankingDocument json) { + Database.BeginTransaction(); + Extended_Document document = json.header; + JObject oldDoc = getCompleteDocument(document.idDocument); + DocType t = checkDocType(document.DocumentTypeId, DocType.Cheque, DocType.Deposit, DocType.CreditCardCharge, DocType.CreditCardCredit); + FullAccount acct = Database.Get((int)document.DocumentAccountId); + checkAcctType(acct.AccountTypeId, AcctType.Bank, AcctType.CreditCard, AcctType.Investment); + allocateDocumentIdentifier(document, acct); + int sign = SignFor(t); + Extended_Document original = getDocument(document); + decimal vat = 0; + decimal net = 0; + bool lineVat = false; // Flag to indicate this is a cheque to pay the VAT to HMRC + foreach (InvoiceLine detail in json.detail) { + net += detail.LineAmount; + vat += detail.VatAmount; + } + Utils.Check(document.DocumentAmount == net + vat, "Document does not balance"); + decimal changeInDocumentAmount = -sign * (document.DocumentAmount - original.DocumentAmount); + int lineNum = 1; + fixNameAddress(document, "O"); + Database.Update(document); + int nextDocid = Utils.ExtractNumber(document.DocumentIdentifier); + if (nextDocid > 0 && acct.RegisterNumber(t, nextDocid)) + Database.Update(acct); + // Find any existing VAT record + Journal vatJournal = Database.QueryOne("SELECT * FROM Journal WHERE DocumentId = " + document.idDocument + + " AND AccountId = " + (int)Acct.VATControl + " ORDER BY JournalNum DESC"); + Journal journal = Database.Get(new Journal() { + DocumentId = (int)document.idDocument, + JournalNum = lineNum + }); + journal.DocumentId = (int)document.idDocument; + journal.AccountId = document.DocumentAccountId; + journal.NameAddressId = document.DocumentNameAddressId; + journal.Memo = document.DocumentMemo; + journal.JournalNum = lineNum++; + journal.Amount += changeInDocumentAmount; + journal.Outstanding += changeInDocumentAmount; + Database.Update(journal); + foreach (InvoiceLine detail in json.detail) { + if (detail.AccountId == 0 || detail.AccountId == null) continue; + Utils.Check(!lineVat, "Cheque to VAT account may only have 1 line"); + if (detail.AccountId == (int)Acct.VATControl) { + // This is a VAT payment to HMRC + Utils.Check(lineNum == 2, "Cheque to VAT account may only have 1 line"); + Utils.Check(vat == 0, "Cheque to VAT account may not have a VAT amount"); + vat = detail.LineAmount; + lineVat = true; + } + journal = Database.Get(new Journal() { + DocumentId = (int)document.idDocument, + JournalNum = lineNum + }); + journal.DocumentId = (int)document.idDocument; + journal.JournalNum = lineNum++; + journal.AccountId = (int)detail.AccountId; + journal.NameAddressId = document.DocumentNameAddressId; + journal.Memo = detail.Memo; + journal.Amount = sign * detail.LineAmount; + journal.Outstanding = sign * detail.LineAmount; + Database.Update(journal); + Line line = new Line(); + line.idLine = journal.idJournal; + line.Qty = 0; + line.LineAmount = detail.LineAmount; + line.VatCodeId = detail.VatCodeId; + line.VatRate = detail.VatRate; + line.VatAmount = detail.VatAmount; + Database.Update(line); + } + Database.Execute("DELETE FROM Line WHERE idLine IN (SELECT idJournal FROM Journal WHERE DocumentId = " + document.idDocument + " AND JournalNum >= " + lineNum + ")"); + Database.Execute("DELETE FROM Journal WHERE DocumentId = " + document.idDocument + " AND JournalNum >= " + lineNum); + if (vat != 0 || vatJournal.idJournal != null) { + // Add the VAT journal at the end + vat *= sign; + decimal changeInVatAmount = vat - vatJournal.Amount; + Utils.Check(document.VatPaid == null || changeInVatAmount == 0, "Cannot alter VAT on this document, it has already been declared"); + if (!lineVat) { + vatJournal.DocumentId = (int)document.idDocument; + vatJournal.AccountId = (int)Acct.VATControl; + vatJournal.NameAddressId = document.DocumentNameAddressId; + vatJournal.Memo = "Total VAT"; + vatJournal.JournalNum = lineNum++; + vatJournal.Amount = vat; + vatJournal.Outstanding += changeInVatAmount; + Database.Update(vatJournal); + } + } + JObject newDoc = getCompleteDocument(document.idDocument); + Database.AuditUpdate("Document", document.idDocument, oldDoc, newDoc); + Settings.RegisterNumber(this, document.DocumentTypeId, Utils.ExtractNumber(document.DocumentIdentifier)); + Database.Commit(); + return new AjaxReturn() { message = "Document saved", id = document.idDocument }; + } + + /// + /// Bank reconciliation + /// + /// + public void Reconcile(int id) { + JObject header = Database.QueryOne("*", "WHERE idAccount = " + id, "Account"); + JObject openingBalance = Database.QueryOne("SELECT SUM(Amount) AS OpeningBalance FROM Journal WHERE AccountId = " + id + + " AND Cleared = 'X'"); + header["OpeningBalance"] = openingBalance == null ? 0 : openingBalance.AsDecimal("OpeningBalance"); + Title += " - " + header.AsString("AccountName"); + checkAccountIsAcctType(id, AcctType.Bank, AcctType.CreditCard); + Record = new JObject().AddRange("header", header, + "detail", Database.Query(@"SELECT Extended_Document.*, Journal.idJournal, Journal.Cleared, Journal.Amount +FROM Journal +JOIN Extended_Document ON idDocument = DocumentId +WHERE Journal.AccountId = " + id + @" +AND Journal.Cleared <> 'X' +ORDER BY DocumentDate, idDocument")); + } + + /// + /// Post bank reconciliation + /// + public AjaxReturn ReconcilePost(ReconcileDocument json) { + // Temporary indicates they haven't finished - no need to check balances, save Clr marks as "*" instead of "X" + string mark = json.Temporary ? "*" : "X"; + decimal bal = json.header.OpeningBalance; + Utils.Check(json.header.idAccount > 0, "Invalid account"); + Database.BeginTransaction(); + if (!json.Temporary) + Database.Audit(AuditType.Reconcile, "Reconciliation", json.header.idAccount, json.ToJson(), null); + Database.Execute("UPDATE Account SET EndingBalance = " + Database.Quote(json.Temporary ? json.header.EndingBalance : null) + + " WHERE idAccount = " + json.header.idAccount); + foreach (ReconcileLine line in json.detail) { + string mk; + if (line.Cleared == "1" || line.Cleared == "*") { + bal += line.Amount; + mk = mark; + } else { + mk = ""; + } + Database.Execute("UPDATE Journal SET Cleared = " + Database.Quote(mk) + " WHERE idJournal = " + line.idJournal); + } + Utils.Check(json.Temporary || json.header.EndingBalance != null, "You must enter an Ending Balance"); + Utils.Check(json.Temporary || bal == json.header.EndingBalance && bal == json.header.ClearedBalance, "Reconcile does not balance"); + Database.Commit(); + return new AjaxReturn() { message = "Reconcile saved", redirect = json.print ? null : "/Banking/Detail?id=" + json.header.idAccount }; + } + + /// + /// Prepare to memorise a transaction for automatic retrieval and posting later. + /// + public void Memorise(int id) { + dynamic record = GetDocument(id, DocType.Cheque); + Utils.Check(record.header.idDocument != null, "Document {0} not found", id); + DocType type = (DocType)record.header.DocumentTypeId; + Schedule job = new Schedule(); + job.ActionDate = record.header.DocumentDate; + job.Task = type.UnCamel() + " " + record.header.DocumentAmount.ToString("0.00") + (type == DocType.Cheque || type == DocType.CreditCardCharge ? " to " : " from ") + record.header.DocumentName + " " + record.header.DocumentMemo; + job.Url = "banking/standingorderpost"; + job.Parameters = record.ToString(); + job.Post = true; + Module = "company"; + Method = "job"; + Record = job; + } + + /// + /// Post a memorised transaction schedule record after editing/review + /// + public AjaxReturn MemorisePost(Schedule json) { + return PostRecord(json, false); + } + + /// + /// Post a memorised transaction, then redirect to it for review + /// + public AjaxReturn StandingOrderPost(BankingDocument json, DateTime date) { + json.header.idDocument = null; + json.header.DocumentDate = date; + if (Utils.ExtractNumber(json.header.DocumentIdentifier) > 0) + json.header.DocumentIdentifier = ""; + AjaxReturn result = DocumentPost(json); + if (result.error == null && result.id > 0) + result.redirect = "/banking/document.html?message=" + json.header.DocType.UnCamel() + "+saved&id=" + result.id + "&type=" + json.header.DocumentTypeId; + return result; + } + + /// + /// Prepare to memorise a transaction for automatic retrieval and posting later. + /// + public void MemoriseTransfer(int id) { + TransferDocument header = GetTransferDocument(id); + Utils.Check(header.idDocument != null, "Transfer {0} not found", id); + Account account = Database.Get((int)header.TransferAccountId); + checkDocType(header.DocumentTypeId, DocType.Transfer); + Schedule job = new Schedule(); + job.ActionDate = header.DocumentDate; + job.Task = "Transfer " + header.DocumentAmount.ToString("0.00") + " from " + header.DocumentAccountName + " to " + account.AccountName + " " + header.DocumentMemo; + job.Url = "banking/repeattransferpost"; + job.Parameters = header.ToString(); + job.Post = true; + Module = "company"; + Method = "job"; + Record = job; + } + + /// + /// Post a memorised transaction schedule record after editing/review + /// + public AjaxReturn MemoriseTransferPost(Schedule json) { + return PostRecord(json, false); + } + + /// + /// Post a memorised transaction, then redirect to it for review + /// + public AjaxReturn RepeatTransferPost(TransferDocument json, DateTime date) { + json.idDocument = null; + json.DocumentDate = date; + AjaxReturn result = TransferPost(json); + if (result.error == null && result.id > 0) + result.redirect = "/banking/transfer.html?message=Transfer+saved&id=" + result.id; + return result; + } + + /// + /// Show ImportHelp template + /// + public void ImportHelp() { + } + + /// + /// Statement import form + /// + public void StatementImport(int id) { + Account account = Database.Get(id); + checkAcctType(account.AccountTypeId, AcctType.Bank, AcctType.CreditCard); + Title += " - " + account.AccountName; + Record = new JObject().AddRange( + "Id", id, + "StatementFormat", account.StatementFormat); + SessionData.Remove("StatementImport"); + SessionData.Remove("StatementMatch"); + } + + /// + /// User wants to import a statement + /// + /// Account + /// Statement format (for pasted statement) + /// Pasted statement + /// Uploaded Qif statement + /// For Qif import + public void StatementImportPost(int id, string format, string data, UploadedFile file, string dateFormat) { + Account account = Database.Get(id); + checkAcctType(account.AccountTypeId, AcctType.Bank, AcctType.CreditCard); + JArray result; + DateTime minDate = DateTime.MaxValue; + if (!string.IsNullOrWhiteSpace(file.Content)) { + // They have uploaded a Qif file + QifImporter qif = new QifImporter(); + qif.DateFormat = dateFormat; + result = qif.ImportTransactions(new System.IO.StreamReader(file.Stream()), this); + Utils.Check(result.Count > 0, "No transactions found"); + minDate = result.Min(i => (DateTime)i["Date"]); + } else { + // They have uploaded pasted data + data = data.Replace("\r", "") + "\n"; + Utils.Check(!string.IsNullOrWhiteSpace(format), "You must enter a Statement Format"); + // See Import Help for details of format notation + format = format.Replace("\r", "").Replace("\t", "{Tab}").Replace("\n", "{Newline}"); + string regex = format + .Replace("{Tab}", @"\t") + .Replace("{Newline}", @"\n"); + regex = Regex.Replace(regex, @"\{Any\}", delegate(Match m) { + // Look at next character + string terminator = regex.Substring(m.Index + m.Length, 1); + switch (terminator) { + case @"\n": + case @"\t": + break; + default: + // Terminate "ignore any" section at next newline or tab + terminator = @"\t\n"; + break; + } + return @"[^" + terminator + @"]*?"; + }); + regex = Regex.Replace(regex, @"\{Optional:([^}]+)\}", "(?:$1)?"); + regex = Regex.Replace(regex, @"\{([^}]+)\}", delegate(Match m) { + // Look at next character + string terminator = m.Index + m.Length >= regex.Length ? "" : regex.Substring(m.Index + m.Length, 1); + switch (terminator) { + case @"\n": + case @"\t": + break; + default: + // Terminate field at next newline or tab + terminator = @"\t\n"; + break; + } + // Create named group with name from inside {} + return @"(?<" + m.Groups[1] + @">[^" + terminator + @"]*?)"; + }); + regex = "(?<=^|\n)" + regex; + result = new JArray(); + Regex r = new Regex(regex, RegexOptions.Singleline); + bool valid = false; + foreach (Match m in r.Matches(data)) { + JObject o = new JObject(); + string value = null; + try { + decimal amount = 0; + foreach (string groupName in r.GetGroupNames()) { + value = m.Groups[groupName].Value; + switch (groupName) { + case "0": + break; + case "Date": + DateTime date = DateTime.Parse(value); + if (date < minDate) + minDate = date; + o["Date"] = date; + break; + case "Amount": + Utils.Check(extractAmount(value, ref amount), "Unrecognised Amount {0}", value); + o["Amount"] = -amount; + break; + case "Payment": + if (extractAmount(value, ref amount)) + o["Amount"] = -Math.Abs(amount); + break; + case "Deposit": + if (extractAmount(value, ref amount)) + o["Amount"] = Math.Abs(amount); + break; + default: + o[groupName] = value; + break; + } + } + Utils.Check(o["Amount"] != null, "No Payment, Deposit or Amount"); + Utils.Check(o["Date"] != null, "No Date"); + valid = true; + } catch (Exception ex) { + o["@class"] = "warning"; + o["Name"] = ex.Message + ":" + value + ":" + m.Value; + } + result.Add(o); + } + if (valid) { + // The format was valid - save it to the account for next time + account.StatementFormat = format; + Database.Update(account); + } + } + JObject record = new JObject().AddRange( + "import", result, + "transactions", potentialMatches(id, minDate) + ); + // Save data to session + SessionData.StatementImport = record; + SessionData.Remove("StatementMatch"); + Redirect("/banking/statementmatching.html?id=" + id); + } + + /// + /// Extract monetary amount from a string. Return true if one was found + /// + static bool extractAmount(string a, ref decimal amount) { + string dot = Regex.Escape(System.Globalization.CultureInfo.CurrentCulture.NumberFormat.CurrencyDecimalSeparator); + Match v = Regex.Match(a.Replace(System.Globalization.CultureInfo.CurrentCulture.NumberFormat.CurrencyGroupSeparator, ""), + @"([^\d" + dot + @"]*)([\d" + dot + @"]+)"); + if (!v.Success) + return false; + amount = decimal.Parse(v.Groups[2].Value); + a = v.Groups[1].Value; + if(a.Contains("-") || a.Contains("CR")) + amount = -amount; + return true; + } + + /// + /// Return all possible potential matches for transactions after minDate - 7 days + /// + IEnumerable potentialMatches(int id, DateTime minDate) { + HashSet existing = new HashSet(); + minDate = minDate.AddDays(-7); + foreach (dynamic doc in DetailListing(id)) { + if (doc.DocumentDate >= minDate || doc.Clr != "X") { + // Unreconciled transaction in date range - all these are possible matches + yield return doc; + } else { + // Otherwise return 1 transaction for each unique Name, Type, Memo + string key = doc.DocumentName + ":" + doc.DocumentTypeId + ":" + doc.DocumentMemo; + if (existing.Contains(key)) + continue; + existing.Add(key); + yield return doc; + } + } + } + + /// + /// Return saved session data for statement matching + /// + public void StatementMatching() { + Record = SessionData.StatementImport; + SessionData.Remove("StatementMatch"); + } + + /// + /// Update 1 matched transaction + /// + public AjaxReturn StatementMatchingPost(MatchInfo json) { + checkAccountIsAcctType(json.id, AcctType.Bank, AcctType.CreditCard); + JObject current = SessionData.StatementImport.import[json.current]; + Utils.Check(current != null, "Current not found"); + if (json.transaction >= 0) { + Extended_Document transaction = SessionData.StatementImport.transactions[json.transaction].ToObject(); + checkDocType(transaction.DocumentTypeId, + DocType.Payment, + DocType.BillPayment, + DocType.Cheque, + DocType.Deposit, + DocType.CreditCardCharge, + DocType.CreditCardCredit, + DocType.Transfer); + } + // Save json to session + SessionData.StatementMatch = json.ToJToken(); + return new AjaxReturn() { redirect = "statementmatch.html?id=" + json.id }; + } + + /// + /// Update a matched transaction + /// + public void StatementMatch() { + Utils.Check(SessionData.StatementMatch != null, "Invalid call to StatementMatch"); + MatchInfo match = SessionData.StatementMatch.ToObject(); + Account account = Database.Get(match.id); + // The existing transaction to match (or empty record if none) + Extended_Document transaction = match.transaction < 0 ? Database.EmptyRecord() : + SessionData.StatementImport.transactions[match.transaction].ToObject(); + // The statement transaction + dynamic current = SessionData.StatementImport.import[match.current]; + Utils.Check(current != null, "No current transaction"); + bool same = match.type == "Same"; + bool documentHasVat = false; + bool payment = false; + decimal cAmount = current.Amount; + int id = transaction.idDocument ?? 0; + DocType type = match.transaction < 0 ? + match.type == "Transfer" ? + DocType.Transfer : // They specified a new transfer + cAmount < 0 ? // They specified a new transaction - type depends on sign and account type + account.AccountTypeId == (int)AcctType.Bank ? + DocType.Cheque : DocType.CreditCardCharge : + account.AccountTypeId == (int)AcctType.Bank ? + DocType.Credit : DocType.CreditCardCredit : + (DocType)transaction.DocumentTypeId; + GetParameters["acct"] = match.id.ToString(); // This bank account + // Call appropriate method to get Record, and therefore transaction + // Also set Module and Method, so appropriate template is used to display transaction before posting + switch (type) { + case DocType.Payment: + Module = "customer"; + Method = "payment"; + Customer cust = new Customer(); + cust.Payment(id); + this.Record = cust.Record; + payment = true; + break; + case DocType.BillPayment: + Module = "supplier"; + Method = "payment"; + Supplier supp = new Supplier(); + supp.Payment(id); + this.Record = supp.Record; + payment = true; + break; + case DocType.Cheque: + case DocType.Deposit: + case DocType.CreditCardCharge: + case DocType.CreditCardCredit: + Method = "document"; + Document(id, type); + documentHasVat = true; + break; + case DocType.Transfer: + Method = "transfer"; + Transfer(id); + break; + default: + throw new CheckException("Unexpected document type:{0}", type.UnCamel()); + } + dynamic record = (JObject)Record; + dynamic doc = record.header; + if (id == 0 && type == DocType.Transfer && cAmount > 0) { + // New transfer in + doc.TransferAccountId = match.id; + doc.DocumentAccountId = 0; + doc.DocumentAccountName = ""; + } + if (string.IsNullOrWhiteSpace(doc.DocumentMemo.ToString())) { + // Generate a memo + string name = current.Name; + string memo = current.Memo; + if (string.IsNullOrWhiteSpace(memo)) + memo = name; + else if (!memo.Contains(name)) + memo = name + " " + memo; + doc.DocumentMemo = memo; + } + if (!same) { + // They want to create a new document - try to guess the DocumentName + string name = doc.DocumentName; + string currentName = current.Name; + currentName = currentName.Split('\n', '\t')[0]; + if (string.IsNullOrWhiteSpace(name) || (!payment && name.SimilarTo(currentName) < 0.5)) { + doc.DocumentName = currentName; + NameAddress n = new NameAddress() { + Type = "O", + Name = currentName + }; + doc.DocumentNameAddressId = Database.Get(n).idNameAddress ?? 0; + } + } + doc.DocumentDate = current.Date; + decimal tAmount = doc.DocumentAmount; + decimal diff = Math.Abs(cAmount) - Math.Abs(tAmount); + doc.DocumentAmount += diff; + if(same) + Utils.Check(diff == 0, "Amounts must be the same"); + else { + // New transaction + doc.DocumentOutstanding = doc.DocumentAmount; + doc.Clr = ""; + doc.idDocument = doc.Id = null; + if (Utils.ExtractNumber(doc.DocumentIdentifier.ToString()) > 0) + doc.DocumentIdentifier = ""; + } + if(string.IsNullOrEmpty(doc.DocumentIdentifier.ToString())) { + if (current.Id != null) { + doc.DocumentIdentifier = current.Id; + } else { + int no = Utils.ExtractNumber(current.Name.ToString()); + if (no != 0) + doc.DocumentIdentifier = no.ToString(); + } + } + if (diff != 0 && documentHasVat) { + // Adjust first line to account for difference + if (record.detail.Count == 0) + record.detail.Add(new InvoiceLine().ToJToken()); + dynamic line = record.detail[0]; + decimal val = line.LineAmount + line.VatAmount + diff; + if (line.VatRate != 0) { + line.VatAmount = Math.Round(val * line.VatRate / (100 + line.VatRate), 2); + val -= line.VatAmount; + } + line.LineAmount = val; + } + if (payment && !same) + removePayments(record); + record.StatementAccount = match.id; + if (same) { + // Just post the new information + if (type == DocType.Transfer) + record = record.header; // Transfer posts header alone + AjaxReturn p = StatementMatchPost((JObject)record); + if (p.error == null) + Redirect(p.redirect); // If no error, go on with matching + } + } + + void removePayments(dynamic record) { + record.header.Allocated = 0M; + record.header.Remaining = (decimal)record.header.DocumentAmount; + int l = record.detail.Count; + while(l-- > 0) { + dynamic line = record.detail[l]; + decimal amountPaid = line.AmountPaid; + if (amountPaid != 0) { + decimal outstanding = line.Outstanding - amountPaid; + if (outstanding == 0) + record.detail.RemoveAt(l); + else + line.Outstanding = outstanding; + } + } + } + + /// + /// Post a matched transaction. + /// May be called direct from StatementMatch for Same transactions, + /// or when the user presses "Save" for other transactions + /// + public AjaxReturn StatementMatchPost(JObject json) { + Utils.Check(SessionData.StatementMatch != null, "Invalid call to StatementMatchPost"); + MatchInfo match = SessionData.StatementMatch.ToObject(); + JArray transactions = SessionData.StatementImport.transactions; + dynamic transaction = match.transaction < 0 ? null : SessionData.StatementImport.transactions[match.transaction]; + DocType type = match.transaction < 0 ? match.type == "Transfer" ? DocType.Transfer : DocType.Cheque : + (DocType)((JObject)transactions[match.transaction]).AsInt("DocumentTypeId"); + AjaxReturn result; + switch (type) { + case DocType.Payment: + result = new Customer() { + Context = Context, + GetParameters = GetParameters, + PostParameters = PostParameters, + Parameters = Parameters, + }.PaymentPost(json.To()); + break; + case DocType.BillPayment: + result = new Supplier() { + Context = Context, + GetParameters = GetParameters, + PostParameters = PostParameters, + Parameters = Parameters, + }.PaymentPost(json.To()); + break; + case DocType.Cheque: + case DocType.Deposit: + case DocType.CreditCardCharge: + case DocType.CreditCardCredit: + result = DocumentPost(json.To()); + break; + case DocType.Transfer: + result = TransferPost(json.To()); + break; + default: + throw new CheckException("Unexpected document type:{0}", type.UnCamel()); + } + if (result.error == null) { + if(match.transaction >= 0 && match.type == "Same") + transaction.Matched = 1; + JArray items = SessionData.StatementImport.import; + items.RemoveAt(match.current); + result.redirect = "/banking/" + (items.Count == 0 ? "detail" : "statementmatching") + ".html?id=" + match.id; + } + return result; + } + + public class MatchInfo : JsonObject { + /// + /// Bank account id + /// + public int id; + /// + /// New, Transfer or Same + /// + public string type; + /// + /// Index of chosen record from statement + /// + public int current; + /// + /// Index of chosen transaction from matched transactions, or -1 if none + /// + public int transaction; + } + + public class BankingDetail : Account { + public decimal? Balance; + public decimal? CurrentBalance; + } + + public class BankingDocument : JsonObject { + public Extended_Document header; + public List detail; + } + + public class ReconcileHeader : Account { + public decimal OpeningBalance; + public decimal ClearedBalance; + } + + public class ReconcileLine : Extended_Document { + public int idJournal; + public string Cleared; + public decimal Amount; + } + + public class ReconcileDocument { + public bool Temporary; + public ReconcileHeader header; + public ReconcileLine[] detail; + public bool print; + } + + } + + public class FullAccount : Account { + public string AcctType; + + public int NextNumber(DocType docType) { + switch (docType) { + case DocType.Cheque: + case DocType.CreditCardCharge: + return NextChequeNumber; + case DocType.Deposit: + case DocType.CreditCardCredit: + return NextDepositNumber; + default: + return 0; + } + } + + public bool RegisterNumber(DocType docType, int current) { + switch (docType) { + case DocType.Cheque: + case DocType.CreditCardCharge: + return registerNumber(ref NextChequeNumber, current); + case DocType.Deposit: + case DocType.CreditCardCredit: + return registerNumber(ref NextDepositNumber, current); + } + return false; + } + + bool registerNumber(ref int next, int current) { + if (current >= next) { + next = current + 1; + return true; + } + return false; + } + + + } +} diff --git a/BankingAccounting.cs b/BankingAccounting.cs new file mode 100644 index 0000000..c44b391 --- /dev/null +++ b/BankingAccounting.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Common functionality from Banking & Accounting + /// + public class BankingAccounting : AppModule { + + public BankingAccounting() { + } + + public void Names() { + // When maintaining names, use the template in banking + Module = "banking"; + } + + public object NamesListing() { + return Database.Query("SELECT * FROM NameAddress WHERE Type = 'O' ORDER BY Name"); + } + + public void Name(int id) { + // When maintaining names, use the template in banking + Module = "banking"; + NameAddress record = Database.Get(id); + // Can only maintain Other names here + if (record.Id == null) + record.Type = "O"; + else { + checkNameType(record.Type, "O"); + Title += " - " + record.Name; + } + Record = record; + } + + public AjaxReturn NamePost(NameAddress json) { + checkNameType(json.Type, "O"); + return PostRecord(json, true); + } + + /// + /// Get an existing transfer document, or fill in a new one + /// + internal TransferDocument GetTransferDocument(int id) { + TransferDocument header = getDocument(id); + int? acct = GetParameters["acct"].IsInteger() ? Parameters.AsInt("acct") : (int?)null; + if (header.idDocument == null) { + header.DocumentTypeId = (int)DocType.Transfer; + header.DocType = DocType.Transfer.UnCamel(); + header.DocumentDate = Utils.Today; + header.DocumentName = ""; + header.DocumentMemo = "Money Transfer"; + if (acct != null) + header.DocumentAccountId = (int)acct; + } else { + checkDocType(header.DocumentTypeId, DocType.Transfer); + header.TransferAccountId = Database.QueryOne("SELECT AccountId FROM Journal WHERE DocumentId = " + id + " AND JournalNum = 2").AsInt("AccountId"); + checkAccountIsAcctType(header.DocumentAccountId, AcctType.Bank, AcctType.CreditCard, AcctType.Investment); + checkAccountIsAcctType(header.TransferAccountId, AcctType.Bank, AcctType.CreditCard, AcctType.Investment); + if (acct == null) + acct = header.DocumentAccountId; + } + return header; + } + + public void Transfer(int id) { + // Use template in banking + Module = "banking"; + TransferDocument header = GetTransferDocument(id); + int? acct = GetParameters["acct"].IsInteger() ? Parameters.AsInt("acct") : (int?)null; + JObject record = new JObject().AddRange("header", header, + "Account", acct, + "BankAccounts", new Select().BankOrStockAccount("")); + if (acct != null) + Database.NextPreviousDocument(record, "JOIN Journal ON DocumentId = idDocument WHERE AccountId = " + + acct + " AND DocumentTypeId = " + (int)DocType.Transfer); + Record = record; + } + + public AjaxReturn TransferPost(TransferDocument json) { + Database.BeginTransaction(); + checkDocType(json.DocumentTypeId, DocType.Transfer); + checkAccountIsAcctType(json.DocumentAccountId, AcctType.Bank, AcctType.CreditCard, AcctType.Investment); + checkAccountIsAcctType(json.TransferAccountId, AcctType.Bank, AcctType.CreditCard, AcctType.Investment); + fixNameAddress(json, "O"); + JObject oldDoc = getCompleteDocument(json.idDocument); + Database.Update(json); + // Transfer has 2 journals, 1 line, no VAT + Journal journal = Database.Get(new Journal() { + DocumentId = (int)json.idDocument, + JournalNum = 1 + }); + journal.DocumentId = (int)json.idDocument; + journal.AccountId = json.DocumentAccountId; + journal.NameAddressId = json.DocumentNameAddressId; + journal.Amount = -json.DocumentAmount; + journal.Outstanding = -json.DocumentAmount; + journal.Memo = json.DocumentMemo; + journal.JournalNum = 1; + Database.Update(journal); + journal = Database.Get(new Journal() { + DocumentId = (int)json.idDocument, + JournalNum = 2 + }); + journal.DocumentId = (int)json.idDocument; + journal.AccountId = (int)json.TransferAccountId; + journal.NameAddressId = json.DocumentNameAddressId; + journal.Amount = json.DocumentAmount; + journal.Outstanding = json.DocumentAmount; + journal.Memo = json.DocumentMemo; + journal.JournalNum = 2; + Database.Update(journal); + Line line = Database.Get(new Line() { + idLine = journal.idJournal + }); + line.idLine = journal.idJournal; + line.LineAmount = json.DocumentAmount; + Database.Update(line); + Database.Update(json); + JObject newDoc = getCompleteDocument(json.idDocument); + Database.AuditUpdate("Document", json.idDocument, oldDoc, newDoc); + Database.Commit(); + return new AjaxReturn() { message = "Transfer saved", id = json.idDocument }; + } + + public AjaxReturn TransferDelete(int id) { + return deleteDocument(id, DocType.Transfer); + } + + public class TransferDocument : Extended_Document { + public int? TransferAccountId; + } + + } +} diff --git a/Company.cs b/Company.cs new file mode 100644 index 0000000..ecfd093 --- /dev/null +++ b/Company.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Reflection; +using System.IO; +using System.Web; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// For Scheduled transactions + /// + enum RepeatType { + None, Daily, Weekly, Monthly, Quarterly, Yearly + } + + /// + /// Company front page, and todo list (including scheduled transactions) + /// + public class Company : AppModule { + + public Company() { + Menu = new MenuOption[] { + new MenuOption("Summary", "/company/default.html"), + new MenuOption("To Do", "/company/schedule.html"), + new MenuOption("New To Do", "/company/job.html?id=0") + }; + } + + public override void Default() { + Record = new JObject().AddRange( + "schedule", DefaultScheduleListing(), + "banking", total(Database.Query("Account.*, AcctType, SUM(Amount) AS Balance", + "WHERE AccountTypeId " + Database.In(AcctType.Bank, AcctType.CreditCard) + + " AND DocumentDate <= " + Database.Quote(Utils.Today) + + " AND HideAccount != 1 GROUP BY idAccount ORDER BY AccountTypeId, AccountName", + "Account", "Journal", "Document"), "AcctType", "Balance"), + "investments", total(Database.Query(@"SELECT Account.*, Amount AS CashBalance, Value +FROM (SELECT AccountId, SUM(Amount) AS Amount FROM Journal GROUP BY AccountId) AS Balances +JOIN Account ON idAccount = Balances.AccountId +JOIN AccountType ON idAccountType = AccountTypeId +LEFT JOIN (" + Investments.AccountValue(Utils.Today) + @") AS AccountValues ON AccountValues.ParentAccountId = Balances.AccountId +WHERE AccountTypeId = " + (int)AcctType.Investment + @" +AND (Amount <> 0 OR Value <> 0) +GROUP BY idAccount ORDER BY AccountName"), "Name", "CashBalance", "Value"), + "customer", total(Database.Query(@"SELECT NameAddress.*, Sum(Outstanding) AS Outstanding +FROM NameAddress +LEFT JOIN Journal ON NameAddressId = idNameAddress +AND AccountId = " + (int)Acct.SalesLedger + @" +WHERE Type='C' +AND Outstanding <> 0 +GROUP BY idNameAddress +ORDER BY Name +"), "Name", "Outstanding"), + "supplier", total(Database.Query(@"SELECT NameAddress.*, Sum(Outstanding) AS Outstanding +FROM NameAddress +LEFT JOIN Journal ON NameAddressId = idNameAddress +AND AccountId = " + (int)Acct.PurchaseLedger + @" +WHERE Type='S' +AND Outstanding <> 0 +GROUP BY idNameAddress +ORDER BY Name +"), "Name", "Outstanding") + ); + } + + public object DefaultScheduleListing() { + return Database.Query("SELECT idSchedule, ActionDate, RepeatType, Task, Post, CASE WHEN ActionDate <= " + Database.Quote(Utils.Today) + " THEN 'due' ELSE NULL END AS \"@class\" FROM Schedule WHERE ActionDate <= " + + Database.Quote(Utils.Today.AddDays(7)) + " ORDER BY ActionDate"); + } + + public decimal NetWorth; + + public void Schedule() { + } + + public object ScheduleListing() { + return Database.Query("SELECT idSchedule, ActionDate, RepeatType, Task, Post FROM Schedule ORDER BY ActionDate"); + } + + /// + /// Get a todo job for editing + /// + public void Job(int id) { + Schedule job = Database.Get(id); + if (job.idSchedule == null) { + job.ActionDate = Utils.Today; + } + Record = job; + } + + public AjaxReturn JobPost(Schedule json) { + return PostRecord(json, false); + } + + public AjaxReturn JobDelete(int id) { + AjaxReturn result = new AjaxReturn(); + try { + Database.Delete("Schedule", id, false); + result.message = "Job deleted"; + } catch { + result.error = "Cannot delete"; + } + return result; + } + + /// + /// Action a job + /// + public AjaxReturn JobAction(int id) { + AjaxReturn ret = new AjaxReturn(); + Schedule job = Database.Get(id); + Utils.Check(job.idSchedule != null, "Job {0} not found", id); + if (!string.IsNullOrWhiteSpace(job.Url)) { + // Job actually does something + if (job.Post) { + // It posts a record + string methodName = job.Url; + string moduleName = Utils.NextToken(ref methodName, "/"); + Type type = AppModule.GetModule(moduleName); + Utils.Check(type != null, "Invalid schedule job {0}", job.Url); + AppModule module = (AppModule)Activator.CreateInstance(type); + module.Context = Context; + module.OriginalModule = module.Module = moduleName.ToLower(); + module.OriginalMethod = module.Method = (string.IsNullOrEmpty(methodName) ? "default" : Path.GetFileNameWithoutExtension(methodName)).ToLower(); + module.GetParameters = new NameValueCollection(); + module.Parameters["json"] = job.Parameters; + module.Parameters["date"] = job.ActionDate; + MethodInfo method; + object o = module.CallMethod(out method); + if (method == null) { + ret.error = "Job url not found " + job.Url; + } else if (method.ReturnType == typeof(AjaxReturn)) { + ret = o as AjaxReturn; + if (ret.error == null && ret.redirect != null) + ret.redirect += "&from=" + HttpUtility.UrlEncode(Parameters.AsString("from")); + ret.id = null; + } else { + throw new CheckException("Unexpected return type {0}", method.ReturnType.Name); + } + } else { + // It just redirects somewhere + ret.redirect = Path.ChangeExtension(job.Url, ".html") + "?id=" + id; + } + } + if (string.IsNullOrEmpty(ret.error)) { + // Update job to say it is done + switch ((RepeatType)job.RepeatType) { + case RepeatType.None: + // No repeat - delete job + Database.Delete(job); + ret.message = "Job deleted"; + return ret; + case RepeatType.Daily: + job.ActionDate = job.ActionDate.AddDays(1); + while (job.ActionDate.DayOfWeek == DayOfWeek.Saturday || job.ActionDate.DayOfWeek == DayOfWeek.Sunday) + job.ActionDate = job.ActionDate.AddDays(1); + break; + case RepeatType.Weekly: + job.ActionDate = job.ActionDate.AddDays(7); + break; + case RepeatType.Monthly: + job.ActionDate = job.ActionDate.AddMonths(1); + break; + case RepeatType.Quarterly: + job.ActionDate = job.ActionDate.AddMonths(3); + break; + case RepeatType.Yearly: + job.ActionDate = job.ActionDate.AddYears(1); + break; + default: + throw new CheckException("Invalid repeat type {0}", job.RepeatType); + } + Database.Update(job); + } + ret.id = job.idSchedule; + return ret; + } + + /// + /// Select all items with a value in one of the field names. + /// If there was at least one, add a total row at the bottom, with each fieldname set to its total, + /// and totalFieldName set to the total of all them. + /// Also add the grand total into NetWorth. + /// + IEnumerable total(IEnumerable list, string totalFieldName, params string[] fieldnames) { + bool addTotal = false; + decimal[] totals = new decimal[fieldnames.Length]; + foreach (JObject j in list) { + bool include = false; + for (int i = 0; i < fieldnames.Length; i++) { + decimal d = j.AsDecimal(fieldnames[i]); + totals[i] += d; + if (d != 0) + include = true; + } + if (include) { + yield return j; + addTotal = true; + } + } + JObject tot = new JObject(); + tot["@class"] = "total"; + tot[totalFieldName] = "Total"; + for (int i = 0; i < fieldnames.Length; i++) { + tot[fieldnames[i]] = totals[i]; + NetWorth += totals[i]; + } + if(addTotal) + yield return tot; + } + + } +} diff --git a/CsvParser.cs b/CsvParser.cs new file mode 100644 index 0000000..8e64c1d --- /dev/null +++ b/CsvParser.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Interface for any file processor, for monitoring progess, and detecting the line number of errors + /// + public interface FileProcessor { + /// + /// Character number reached in the whole file + /// + int Character { get; } + + /// + /// Line number reached + /// + int Line { get; } + } + + /// + /// Parse a Csv or Tab delimited file + /// + public class CsvParser : FileProcessor { + /// + /// The current field + /// + StringBuilder _field; + /// + /// List of fields in the current line + /// + List _line; + // States + static State Start = new StartState(); + static TabState TabStart = new TabState(); + static State FieldData = new State(); + static State QuotedField = new QuotedFieldState(); + /// + /// True if at end of file + /// + bool _eof; + /// + /// Line number reached + /// + int _lineno; + TextReader _reader; + /// + /// The start to start each line at. + /// + State _startState = Start; + + /// + /// Add a character to the current field + /// + void AddChar(int ch) { + _field.Append((char)ch); + } + + /// + /// Add current field to the line, and reset + /// + void AddField() { + _line.Add(_field.ToString()); + _field.Length = 0; + } + + /// + /// Read the next character + /// + int ReadChar() { + for (; ; ) { + int ch = _reader.Read(); + Character++; + switch (ch) { + case -1: + _eof = true; + return ch; + case '\r': + continue; + case '\n': + _lineno++; + return ch; + default: + return ch; + } + } + } + + /// + /// State when processing a normal field + /// + class State { + public virtual State process(CsvParser p) { + for(;;) { + int ch = p.ReadChar(); + switch(ch) { + case '\n': + case -1: + // End of line/file - add current field + p.AddField(); + return null; + case '\t': + // This must be a tab-delimited file - switch mode to tab delimited + TabStart.AddField(p); + p._startState = TabStart; + return TabStart; + case ',': + // End of field + p.AddField(); + return Start; // Initial field state + default: + // Add character to field + p.AddChar(ch); + continue; + } + } + } + } + /// + /// Initial state when processing a field + /// + class StartState : State { + public override State process(CsvParser p) { + for(;;) { + int ch = p.ReadChar(); + switch(ch) { + case '\n': + case -1: + if (p._line.Count > 0) p.AddField(); + return null; + case '\t': + // This must be a tab-delimited file - switch mode to tab delimited + TabStart.AddField(p); + p._startState = TabStart; + return TabStart; + case ',': + // Empty field + p.AddField(); + continue; + case '"': + return QuotedField; + default: + // Anything else is a normal field - switch state to field processor + p.AddChar(ch); + return FieldData; + } + } + } + } + /// + /// Tab delimited file processor + /// + class TabState : State { + public override State process(CsvParser p) { + for (; ; ) { + int ch = p.ReadChar(); + switch (ch) { + case '\n': + case -1: + if (p._line.Count > 0 || p._field.Length > 0) AddField(p); + return null; + case '\t': + AddField(p); + continue; + default: + p.AddChar(ch); + continue; + } + } + } + + /// + /// Switching to this state from Csv - remove any quotes from current field, and add it + /// + public void AddField(CsvParser p) { + string f = p._field.ToString(); + if (f.StartsWith("\"") && f.EndsWith("\"")) { + p._field.Length = 0; + p._field.Append(f.Substring(1, f.Length - 2)); + } + p.AddField(); + } + } + /// + /// Special state for quoted fields (which may contain newlines) + /// + class QuotedFieldState : State { + public override State process(CsvParser p) { + for(;;) { + int ch = p.ReadChar(); + switch(ch) { + case -1: + p.AddField(); + return null; + case '"': + ch = p.ReadChar(); + switch(ch) { + case '\n': + case -1: + // Quote at end of line/file just terminates the field + p.AddField(); + return null; + case ',': + // Quote, comma is just end of field + p.AddField(); + return Start; + case '\t': + // Must be a tab-delimited file - switch + p.AddField(); + return TabStart; + case '"': + // Quote Quote = Quote + p.AddChar('"'); + continue; + default: + // Quote anychar is a syntax error, but we allow it and pass through unchanged + p.AddChar('"'); + p.AddChar(ch); + continue; + } + default: + // This includes newlines and tabs! + p.AddChar(ch); + continue; + } + } + } + } + + + public CsvParser(TextReader r) { + _reader = r; + _lineno = 1; + _field = new StringBuilder(); + Headers = ReadLine(); + } + + /// + /// Set to true to turn off validation that there must be the same number of fields as headings + /// + public bool PermitAnyFieldCount; + + /// + /// Read a record and return it as a JObject, using the heading line to name the fields + /// + public IEnumerable Read() { + while (!_eof) { + string [] l = ReadLine(); + if(l.Length == 0) continue; + Utils.Check(PermitAnyFieldCount || l.Length == Headers.Length, "Wrong number of fields in line {0} found:{1} expected {2}", Line, l.Length, Headers.Length); + JObject data = new JObject(); + int max = Math.Min(l.Length, Headers.Length); + for (int i = 0; i < max; i++) { + data[Headers[i]] = l[i]; + } + yield return data; + } + } + + /// + /// The current record as an array of strings + /// + public string[] Data { + get { return _line.ToArray(); } + } + + /// + /// The headers from the header line (i.e. the field names) + /// + public string[] Headers; + + /// + /// For progress display + /// + public int Character { get; private set; } + + /// + /// For error tracking + /// + public int Line { get; private set; } + + /// + /// Read a record and return it as an array of strings + /// + public string [] ReadLine() { + _field.Length = 0; + _line = new List(); + Line = _lineno; + State state = _startState; + do { + state = state.process(this); + } while(state != null); + return _line.ToArray(); + } + } +} diff --git a/Customer.cs b/Customer.cs new file mode 100644 index 0000000..cd14d09 --- /dev/null +++ b/Customer.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Net; +using System.Net.Mail; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Customers and suppliers have some code in common + /// + public class Customer : CustomerSupplier { + + public Customer() + : base("C", Acct.SalesLedger, DocType.Invoice, DocType.CreditMemo, DocType.Payment) { + insertMenuOption(new MenuOption("Products", "/customer/products.html")); + } + + /// + /// All documents for this customer + /// + public object DetailListing(int id) { + return Database.Query("Document.*, DocType, Amount, Outstanding", + "WHERE AccountId = " + (int)LedgerAccount + " AND NameAddressId = " + id + " ORDER BY DocumentDate, idDocument", + "Document", "Journal"); + } + + /// + /// Get an individual document for editing + /// + public void Document(int id, DocType type) { + JObject record = document(id, type); // implemented in base class + record["detail"] = Database.Query("idJournal, DocumentId, ProductId, Line.VatCodeId, VatRate, JournalNum, Journal.AccountId, Memo, UnitPrice, Qty, LineAmount, VatAmount, Unit", + "WHERE Journal.DocumentId = " + id + " AND idLine IS NOT NULL ORDER BY JournalNum", + "Document", "Journal", "Line"); + record.Add("Products", new Select().Product("")); + Record = record; + } + + /// + /// Work out the changes when a Payment is edited + /// + protected override void calculatePaymentChanges(PaymentDocument json, decimal amount, out decimal changeInDocumentAmount, out decimal changeInOutstanding) { + PaymentHeader document = json.header; + PaymentHeader original = getDocument(document); + changeInDocumentAmount = document.DocumentAmount - original.DocumentAmount; + changeInOutstanding = original.DocumentOutstanding + changeInDocumentAmount - amount; + Utils.Check(document.DocumentOutstanding == document.Remaining, "Remaining {0:0.00} does not agree with outstanding {1:0.00}", + document.Remaining, document.DocumentOutstanding); + } + + public void Print(int id) { + prepareInvoice(id); + WriteResponse(LoadTemplate("customer/print", this), "text/html", System.Net.HttpStatusCode.OK); + } + + public void Download(int id) { + Response.Headers["Content-disposition"] = "attachment; filename=S" + id + ".html"; + Print(id); + } + + public AjaxReturn Email(int id) { + Extended_Document header = prepareInvoice(id); + NameAddress customer = Database.Get((int)header.DocumentNameAddressId); + Utils.Check(!string.IsNullOrEmpty(Settings.CompanyEmail), "Company has no email address"); + Utils.Check(!string.IsNullOrEmpty(customer.Email), "Customer has no email address"); + Utils.Check(customer.Email.Contains('@'), "Customer has an invalid email address"); + ((JObject)((JObject)Record)["header"])["doctype"] = header.DocType.ToLower(); + ((JObject)Record)["customer"] = customer.ToJToken(); + string text = LoadTemplate("customer/email.txt", this); + string subject = Utils.NextToken(ref text, "\n").Trim(); + using (MemoryStream stream = new MemoryStream(Encoding.GetBytes(LoadTemplate("customer/print", this)))) { + // Create a message and set up the recipients. + MailMessage message = new MailMessage(); + message.From = new MailAddress(Settings.CompanyEmail); + foreach(string e in customer.Email.Split(',')) + message.To.Add(e); + message.Bcc.Add(Settings.CompanyEmail); + message.Subject = subject; + message.Body = text; + // Create the file attachment for this e-mail message. + Attachment data = new Attachment(stream, Settings.CompanyName + "Invoice" + header.DocumentIdentifier + ".html", "text/html"); + // Add the file attachment to this e-mail message. + message.Attachments.Add(data); + + //Send the message. + SmtpClient client = new SmtpClient(Settings.MailServer); + // Add credentials if the SMTP server requires them. + client.Credentials = new NetworkCredential(Settings.MailUserName, Settings.MailPassword); + client.Port = Settings.MailPort; + client.EnableSsl = Settings.MailSSL; + client.Send(message); + } + return new AjaxReturn() { message = "Email sent to " + customer.Email }; + } + + /// + /// Prepare an invoice for printing/saving/emailing + /// + Extended_Document prepareInvoice(int id) { + Extended_Document header = getDocument(id); + Utils.Check(header.idDocument != null, "Document not found"); + DocType type = (DocType)header.DocumentTypeId; + checkNameType(header.DocumentNameAddressId, NameType); + Title = Title.Replace("Document", type.UnCamel()); + if (SignFor(type) > 0) { + header.DocumentAmount = -header.DocumentAmount; + header.DocumentOutstanding = -header.DocumentOutstanding; + } + List detail = Database.Query("SELECT * FROM Extended_Line WHERE DocumentId = " + id + " ORDER BY JournalNum").ToList(); + JObject record = new JObject().AddRange("header", header, "detail", detail); + decimal net = 0, vat = 0; + foreach (Extended_Line d in detail) { + net += d.LineAmount; + vat += d.VatAmount; + } + record["TotalVat"] = vat; + record["TotalNet"] = net; + record["Total"] = net + vat; + Record = record; + return header; + } + + public void Products() { + } + + public object ProductsListing() { + return Database.Query("*", "ORDER BY ProductName", "Product"); + } + + public void Product(int id) { + Select sel = new Select(); + JObject inUse = Database.QueryOne("SELECT idLine FROM Line WHERE ProductId = " + id); + Record = new JObject().AddRange("header", Database.Get(id), + "canDelete", inUse == null || inUse.IsAllNull(), + "VatCodes", sel.VatCode(""), + "Accounts", sel.IncomeAccount("")); + } + + public AjaxReturn ProductDelete(int id) { + AjaxReturn result = new AjaxReturn(); + try { + Database.Delete("Product", id, true); + result.message = "Product deleted"; + } catch { + result.error = "Cannot delete - Product in use"; + } + return result; + } + + public AjaxReturn ProductPost(Product json) { + return PostRecord(json, true); + } + + } + +#pragma warning disable 0649 + class FullProduct : Product { + public string Code; + public string AccountName; + } +#pragma warning restore 0649 + +} + diff --git a/CustomerSupplier.cs b/CustomerSupplier.cs new file mode 100644 index 0000000..69f81ac --- /dev/null +++ b/CustomerSupplier.cs @@ -0,0 +1,489 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Common code for customer & supplier + /// + public abstract class CustomerSupplier : AppModule { + /// + /// "S" for supplier, "C" for customer + /// + public string NameType; + /// + /// "Customer" or "Supplier" + /// + public string Name; + /// + /// The different document types + /// + public DocType InvoiceDoc, CreditDoc, PaymentDoc; + /// + /// Purchase Ledger or Sales Ledger + /// + public Acct LedgerAccount; + + public CustomerSupplier(string nameType, Acct ledgerAccount, DocType invoiceDoc, DocType creditDoc, DocType paymentDoc) { + NameType = nameType; + Name = NameType.NameType(); + LedgerAccount = ledgerAccount; + InvoiceDoc = invoiceDoc; + CreditDoc = creditDoc; + PaymentDoc = paymentDoc; + string module = nameType == "C" ? "/customer/" : "/supplier/"; + Menu = new MenuOption[] { + new MenuOption("Listing", module + "default.html"), + new MenuOption("VAT codes", module + "vatcodes.html"), + new MenuOption("New " + Name, module + "detail.html?id=0"), + new MenuOption("New " + InvoiceDoc.UnCamel(), module + "document.html?id=0&type=" + (int)InvoiceDoc), + new MenuOption("New " + CreditDoc.UnCamel(), module + "document.html?id=0&type=" + (int)CreditDoc), + new MenuOption("New " + PaymentDoc.UnCamel(), module + "payment.html?id=0") + }; + } + + /// + /// All Customers or Suppliers + /// + public object DefaultListing() { + return Database.Query(@"SELECT NameAddress.*, Count(Outstanding) AS Outstanding +FROM NameAddress +LEFT JOIN Journal ON NameAddressId = idNameAddress +AND AccountId = " + (int)LedgerAccount + @" +AND Outstanding <> 0 +WHERE Type=" + Database.Quote(NameType) + @" +GROUP BY idNameAddress +ORDER BY Name +"); + } + + /// + /// Get record for editing + /// + public void Detail(int id) { + RecordDetail record = Database.QueryOne(@"SELECT NameAddress.*, Sum(Outstanding) AS Outstanding +FROM NameAddress +LEFT JOIN Journal ON NameAddressId = idNameAddress +AND AccountId = " + (int)LedgerAccount + @" +AND Outstanding <> 0 +WHERE idNameAddress = " + id +); + if (record.Id == null) + record.Type = NameType; + else { + checkNameType(record.Type, NameType); + addNameToMenuOptions((int)record.Id); + Title += " - " + record.Name; + } + Record = record; + } + + public AjaxReturn DetailPost(NameAddress json) { + checkNameType(json.Type, NameType); + return PostRecord(json, true); + } + + /// + /// Retrieve document, or prepare new one + /// + public JObject document(int id, DocType type) { + Title = Title.Replace("Document", type.UnCamel()); + Extended_Document header = getDocument(id); + if (header.idDocument == null) { + header.DocumentTypeId = (int)type; + header.DocType = type.UnCamel(); + header.DocumentDate = Utils.Today; + header.DocumentName = ""; + header.DocumentIdentifier = Settings.NextNumber(type).ToString(); + if (GetParameters["name"].IsInteger()) { + JObject name = Database.QueryOne("*", "WHERE Type = " + Database.Quote(NameType) + " AND idNameAddress = " + GetParameters["name"], "NameAddress"); + if (name != null) { + checkNameType(name.AsString("Type"), NameType); + header.DocumentNameAddressId = name.AsInt("idNameAddress"); + header.DocumentAddress = name.AsString("Address"); + header.DocumentName = name.AsString("Name"); + } + } + } else { + checkDocType(header.DocumentTypeId, type); + checkNameType(header.DocumentNameAddressId, NameType); + } + JObject record = new JObject().AddRange("header", header); + Database.NextPreviousDocument(record, "WHERE DocumentTypeId = " + (int)type); + Select s = new Select(); + record.AddRange("VatCodes", s.VatCode(""), + "Names", s.Name(NameType, "")); + return record; + } + + /// + /// Update a document after editing + /// + public AjaxReturn DocumentPost(InvoiceDocument json) { + Database.BeginTransaction(); + Extended_Document document = json.header; + DocType t = checkDocType(document.DocumentTypeId, InvoiceDoc, CreditDoc); + JObject oldDoc = getCompleteDocument(document.idDocument); + int sign = SignFor(t); + Extended_Document original = getDocument(document); + decimal vat = 0; + decimal net = 0; + if (document.idDocument == null) + allocateDocumentIdentifier(document); + foreach (InvoiceLine detail in json.detail) { + if ((detail.ProductId == 0 || detail.ProductId == null) + && (detail.AccountId == 0 || detail.AccountId == null)) + continue; + net += detail.LineAmount; + vat += detail.VatAmount; + } + Utils.Check(document.DocumentAmount == net + vat, "Document does not balance"); + decimal changeInDocumentAmount = -sign * (document.DocumentAmount - original.DocumentAmount); + var lineNum = 1; + fixNameAddress(document, NameType); + Database.Update(document); + // Find any existing VAT record + Journal vatJournal = Database.QueryOne("SELECT * FROM Journal WHERE DocumentId = " + document.idDocument + + " AND AccountId = " + (int)Acct.VATControl + " ORDER BY JournalNum DESC"); + Journal journal = Database.Get(new Journal() { + DocumentId = (int)document.idDocument, + JournalNum = lineNum + }); + journal.DocumentId = (int)document.idDocument; + journal.JournalNum = lineNum++; + journal.AccountId = (int)LedgerAccount; + journal.NameAddressId = document.DocumentNameAddressId; + journal.Memo = document.DocumentMemo; + journal.Amount += changeInDocumentAmount; + journal.Outstanding += changeInDocumentAmount; + Database.Update(journal); + foreach (InvoiceLine detail in json.detail) { + if ((detail.ProductId == 0 || detail.ProductId == null) + && (detail.AccountId == 0 || detail.AccountId == null)) + continue; + journal = Database.Get(new Journal() { + DocumentId = (int)document.idDocument, + JournalNum = lineNum + }); + journal.DocumentId = (int)document.idDocument; + journal.JournalNum = lineNum++; + journal.AccountId = (int)detail.AccountId; + journal.NameAddressId = document.DocumentNameAddressId; + journal.Memo = detail.Memo; + journal.Outstanding += sign * detail.LineAmount - journal.Amount; + journal.Amount = sign * detail.LineAmount; + Database.Update(journal); + Line line = new Line(); + line.idLine = journal.idJournal; + line.Qty = detail.Qty; + line.ProductId = detail.ProductId; + line.LineAmount = detail.LineAmount; + line.VatCodeId = detail.VatCodeId; + line.VatRate = detail.VatRate; + line.VatAmount = detail.VatAmount; + Database.Update(line); + } + Database.Execute("DELETE FROM Line WHERE idLine IN (SELECT idJournal FROM Journal WHERE DocumentId = " + document.idDocument + " AND JournalNum >= " + lineNum + ")"); + Database.Execute("DELETE FROM Journal WHERE DocumentId = " + document.idDocument + " AND JournalNum >= " + lineNum); + if (vat != 0 || vatJournal.idJournal != null) { + vat *= sign; + decimal changeInVatAmount = vat - vatJournal.Amount; + Utils.Check(document.VatPaid == null || changeInVatAmount == 0, "Cannot alter VAT on this document, it has already been declared"); + vatJournal.DocumentId = (int)document.idDocument; + vatJournal.AccountId = (int)Acct.VATControl; + vatJournal.NameAddressId = document.DocumentNameAddressId; + vatJournal.Memo = "Total VAT"; + vatJournal.JournalNum = lineNum++; + vatJournal.Amount = vat; + vatJournal.Outstanding += changeInVatAmount; + Database.Update(vatJournal); + } + JObject newDoc = getCompleteDocument(document.idDocument); + Database.AuditUpdate("Document", document.idDocument, oldDoc, newDoc); + Settings.RegisterNumber(this, document.DocumentTypeId, Utils.ExtractNumber(document.DocumentIdentifier)); + Database.Commit(); + return new AjaxReturn() { message = "Document saved", id = document.idDocument }; + } + + public AjaxReturn DocumentDelete(int id) { + return deleteDocument(id, InvoiceDoc, CreditDoc); + } + + /// + /// Retrieve a payment for editing + /// + public void Payment(int id) { + PaymentDocument document = getPayment(id); + JObject record = document.ToJObject(); + Database.NextPreviousDocument(record, "WHERE DocumentTypeId = " + (int)PaymentDoc); + Select s = new Select(); + record.Add("BankAccounts", s.BankAccount("")); + record.Add("Names", s.Name(NameType, "")); + Record = record; + } + + /// + /// Retrieve a payment, or prepare a new one + /// + PaymentDocument getPayment(int? id) { + PaymentHeader header = getDocument(id); + if (header.idDocument == null) { + header.DocumentTypeId = (int)PaymentDoc; + header.DocumentDate = Utils.Today; + header.DocumentName = ""; + header.DocumentIdentifier = "Payment"; + if(Settings.DefaultBankAccount != null) + header.DocumentAccountId = (int)Settings.DefaultBankAccount; + if (GetParameters["name"].IsInteger()) { + JObject name = Database.QueryOne("*", "WHERE idNameAddress = " + GetParameters["name"], "NameAddress"); + if (name != null) { + checkNameType(name.AsString("Type"), NameType); + header.DocumentNameAddressId = name.AsInt("idNameAddress"); + header.DocumentAddress = name.AsString("Address"); + header.DocumentName = name.AsString("Name"); + } + } + } else { + checkDocType(header.DocumentTypeId, PaymentDoc); + checkNameType(header.DocumentNameAddressId, NameType); + } + PaymentDocument previous = new PaymentDocument(); + previous.header = header; + previous.detail = PaymentListing(header.idDocument, header.DocumentNameAddressId).ToList(); + return previous; + } + + /// + /// Get the payment details of an existing payment from the audit trail. + /// Finds the most recent audit record. + /// + public PaymentDocument PaymentGetAudit(int? id) { + if (id > 0) { + AuditTrail t = Database.QueryOne("SELECT * FROM AuditTrail WHERE TableName = 'Payment' AND ChangeType <= " + + (int)AuditType.Update + " AND RecordId = " + id + " ORDER BY DateChanged DESC"); + if(!string.IsNullOrEmpty(t.Record)) + return JObject.Parse(t.Record).ToObject(); + } + return null; + } + + /// + /// List all the documents with an outstanding amount. + /// For an existing document, also include all other documents that were paid by this one. + /// + public IEnumerable PaymentListing(int? id, int? name) { + if (name > 0) { + if (id == null) + id = 0; + return Database.Query("SELECT Document.*, DocType, " + + (LedgerAccount == Acct.PurchaseLedger ? + "-Amount AS Amount, CASE WHEN PaymentAmount IS NULL THEN -Outstanding ELSE PaymentAmount - Outstanding END AS Outstanding" : + "Amount, CASE WHEN PaymentAmount IS NULL THEN Outstanding ELSE PaymentAmount + Outstanding END AS Outstanding") + + @", CASE WHEN PaymentAmount IS NULL THEN 0 ELSE PaymentAmount END AS AmountPaid +FROM Document +JOIN Journal ON DocumentId = idDocument AND AccountId = " + (int)LedgerAccount + @" +JOIN DocumentType ON idDocumentType = DocumentTypeId +LEFT JOIN Payments ON idPaid = idDocument AND idPayment = " + id + @" +WHERE NameAddressId = " + name + @" +AND (Outstanding <> 0 OR PaymentAmount IS NOT NULL) +ORDER BY DocumentDate"); + } + return new List(); + } + + public AjaxReturn PaymentPost(PaymentDocument json) { + decimal amount = 0; + Database.BeginTransaction(); + PaymentHeader document = json.header; + checkDocType(document.DocumentTypeId, PaymentDoc); + checkNameType(document.DocumentNameAddressId, NameType); + checkAccountIsAcctType(document.DocumentAccountId, AcctType.Bank, AcctType.CreditCard); + if (document.idDocument == null) + allocateDocumentIdentifier(document); + PaymentDocument oldDoc = getPayment(document.idDocument); + int sign = -SignFor(PaymentDoc); + // Update the outstanding on the paid documents + foreach (PaymentLine payment in json.detail) { + decimal a = payment.AmountPaid; + PaymentLine old = oldDoc.PaymentFor(payment.idDocument); + if (old != null) + a -= old.AmountPaid; // reduce update by the amount paid last time it was saved + int? docId = payment.idDocument; + if (a != 0) { + Database.Execute("UPDATE Journal SET Outstanding = Outstanding - " + sign * a + + " WHERE DocumentId = " + Database.Quote(docId) + " AND AccountId = " + (int)LedgerAccount); + amount += a; + } + } + json.detail = json.detail.Where(l => l.AmountPaid != 0).ToList(); + decimal changeInDocumentAmount; + decimal changeInOutstanding; + // Virtual method, as calculation is different for customers and suppliers + calculatePaymentChanges(json, amount, out changeInDocumentAmount, out changeInOutstanding); + document.DocumentTypeId = (int)PaymentDoc; + Database.Update(document); + // Now delete the old cross reference records, and replace with new + Database.Execute("DELETE FROM Payments WHERE idPayment = " + document.idDocument); + foreach (PaymentLine payment in json.detail) { + if (payment.AmountPaid != 0) { + Database.Execute("INSERT INTO Payments (idPayment, idPaid, PaymentAmount) VALUES(" + + document.idDocument + ", " + payment.idDocument + ", " + payment.AmountPaid + ")"); + } + } + // Journal between bank account and sales/purchase ledger + Journal journal = Database.Get(new Journal() { + DocumentId = (int)document.Id, + JournalNum = 1 + }); + journal.DocumentId = (int)document.idDocument; + journal.JournalNum = 1; + journal.NameAddressId = document.DocumentNameAddressId; + journal.Memo = document.DocumentMemo; + journal.AccountId = document.DocumentAccountId; + journal.Amount += changeInDocumentAmount; + journal.Outstanding += changeInOutstanding; + Database.Update(journal); + journal = Database.Get(new Journal() { + DocumentId = (int)document.Id, + JournalNum = 2 + }); + journal.DocumentId = (int)document.idDocument; + journal.JournalNum = 2; + journal.NameAddressId = document.DocumentNameAddressId; + journal.Memo = document.DocumentMemo; + journal.AccountId = (int)LedgerAccount; + journal.Amount -= changeInDocumentAmount; + journal.Outstanding -= changeInOutstanding; + Database.Update(journal); + Line line = Database.Get(new Line() { idLine = journal.idJournal }); + line.idLine = journal.idJournal; + line.LineAmount += PaymentDoc == DocType.BillPayment ? -changeInDocumentAmount : changeInDocumentAmount; + Database.Update(line); + oldDoc = PaymentGetAudit(document.idDocument); + Database.AuditUpdate("Payment", document.idDocument, oldDoc == null ? null : oldDoc.ToJObject(), json.ToJObject()); + Database.Commit(); + return new AjaxReturn() { message = "Payment saved", id = document.idDocument }; + } + + protected abstract void calculatePaymentChanges(PaymentDocument json, decimal amount, out decimal changeInDocumentAmount, out decimal changeInOutstanding); + + public AjaxReturn PaymentDelete(int id) { + return deleteDocument(id, PaymentDoc); + } + + /// + /// Show payment history for a document (either a payment or an invoice/credit) + /// + public void PaymentHistory(int id) { + Extended_Document document = getDocument(id); + bool payment; + Utils.Check(document.DocumentTypeId != 0, "Document {0} not found", id); + switch ((DocType)document.DocumentTypeId) { + case DocType.Invoice: + case DocType.CreditMemo: + case DocType.Bill: + case DocType.Credit: + payment = false; + break; + case DocType.Payment: + case DocType.BillPayment: + payment = true; + break; + default: + throw new CheckException("No Payment History for {0}s", ((DocType)document.DocumentTypeId).UnCamel()); + } + Record = new JObject().AddRange( + "header", document, + "detail", Database.Query(@"SELECT * FROM Payments +JOIN Extended_Document ON idDocument = " + (payment ? "idPaid" : "idPayment") + @" +WHERE " + (payment ? "idPayment" : "idPaid") + " = " + id + @" +ORDER BY DocumentDate, idDocument")); + } + + public void VatCodes() { + // Use customer template + Module = "customer"; + } + + public object VatCodesListing() { + return Database.Query("*", "ORDER BY Code", "VatCode"); + } + + public void VatCode(int id) { + // Use customer template + Module = "customer"; + VatCode record = Database.Get(id); + if (record.Id != null) + Title += " - " + record.Code + ":" + record.VatDescription; + Record = record; + } + + public AjaxReturn VatCodeDelete(int id) { + AjaxReturn result = new AjaxReturn(); + try { + Database.Delete("VatCode", id, true); + result.message = "VAT code deleted"; + } catch { + result.error = "Cannot delete - VAT code in use"; + } + return result; + } + + public AjaxReturn VatCodePost(VatCode json) { + return PostRecord(json, true); + } + + void addNameToMenuOptions(int id) { + foreach (MenuOption option in Menu) + if(option.Text.StartsWith("New ")) + option.Url += "&name=" + id; + } + + public class RecordDetail : NameAddress { + public decimal? Outstanding; + } + + public class InvoiceDocument : JsonObject { + public Extended_Document header; + public List detail; + } + + public class PaymentLine : Document { + public string DocType; + public decimal Amount; + public decimal Outstanding; + public decimal AmountPaid; + } + + public class PaymentHeader : Extended_Document { + public decimal Allocated; + public decimal Remaining; + } + + public class PaymentDocument : JsonObject { + + public PaymentDocument() { + detail = new List(); + } + + public PaymentHeader header; + public List detail; + + public PaymentLine PaymentFor(int? documentId) { + return detail.FirstOrDefault(d => d.idDocument == documentId); + } + } + + } + + public class InvoiceLine : Line { + public int? AccountId; + public string Memo; + } + +} + diff --git a/DDLAttributes.cs b/DDLAttributes.cs new file mode 100644 index 0000000..2b3fa80 --- /dev/null +++ b/DDLAttributes.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace AccountServer { + /// + /// This class is stored in a database table + /// + public class TableAttribute : Attribute { + } + + /// + /// This class is be filled from a view + /// + public class ViewAttribute : Attribute { + public ViewAttribute(string sql) { + Sql = sql; + } + + public string Sql; + } + + /// + /// Unique index. Use more than 1 with the same name for compound keys. + /// + public class UniqueAttribute : Attribute { + + public UniqueAttribute(string name) + : this(name, 0) { + } + + public UniqueAttribute(string name, int sequence) { + Name = name; + Sequence = sequence; + } + + public string Name { get; private set; } + + public int Sequence { get; private set; } + } + + /// + /// Primary index. Use more than 1 for compound primary keys. + /// + public class PrimaryAttribute : Attribute { + + public PrimaryAttribute() + : this(0) { + } + + public PrimaryAttribute(int sequence) { + Name = "PRIMARY"; + Sequence = sequence; + } + + public bool AutoIncrement = true; + + public string Name { get; private set; } + + public int Sequence { get; private set; } + } + + /// + /// This field relates to a master record on another table + /// + public class ForeignKeyAttribute : Attribute { + public ForeignKeyAttribute(string table) { + Table = table; + } + + public string Table { get; private set; } + } + + /// + /// Is allowed to be null. + /// + public class NullableAttribute : Attribute { + } + + /// + /// Length - use 0 for Memo string fields, otherwise strings will have length 45. + /// Decimals are 10.2 by default, doubles 10.4 + /// + public class LengthAttribute : Attribute { + public LengthAttribute(int length) : this(length, 0) { + } + + public LengthAttribute(int length, int precision) { + Length = length; + Precision = precision; + } + + public int Length; + + public int Precision; + } + + public class DefaultValueAttribute : Attribute { + public DefaultValueAttribute(string value) { + Value = value; + } + + public DefaultValueAttribute(int value) { + Value = value.ToString(); + } + + public DefaultValueAttribute(bool value) { + Value = value ? "1" : "0"; + } + + public string Value; + } + + /// + /// This field is not stored on the database. + /// + public class DoNotStoreAttribute : Attribute { + } + + /// + /// Class to build a data dictionary from the code + /// + public class CodeFirst { + Dictionary _tables; + Dictionary _foreignKeys; + + public CodeFirst() { + _tables = new Dictionary(); + var baseType = typeof(JsonObject); + var assembly = baseType.Assembly; + _foreignKeys = new Dictionary(); + foreach (Type tbl in assembly.GetTypes().Where(t => t.BaseType == baseType)) { + if(!tbl.IsDefined(typeof(TableAttribute))) + continue; + processTable(tbl, null); + } + foreach (Field fld in _foreignKeys.Keys) { + ForeignKeyAttribute fk = _foreignKeys[fld]; + Table tbl = TableFor(fk.Table); + fld.ForeignKey = new ForeignKey(tbl, tbl.Fields[0]); + } + foreach (Type tbl in assembly.GetTypes().Where(t => t.BaseType == baseType)) { + ViewAttribute view = tbl.GetCustomAttribute(); + if (view == null) + continue; + processTable(tbl, view); + } + _foreignKeys = null; + } + + public Dictionary Tables { + get { return new Dictionary(_tables); } + } + + public IEnumerable TableNames { + get { return _tables.Where(t => !t.Value.IsView).Select(t => t.Key); } + } + + public IEnumerable ViewNames { + get { return _tables.Where(t => t.Value.IsView).Select(t => t.Key); } + } + + public Table TableFor(string name) { + Table table; + Utils.Check(_tables.TryGetValue(name, out table), "Table '{0}' does not exist", name); + return table; + } + + void processTable(Type tbl, ViewAttribute view) { + List fields = new List(); + // Indexes by name + Dictionary>> indexes = new Dictionary>>(); + // Primary key fields by sequence + List> primary = new List>(); + string primaryName = null; + foreach (FieldInfo field in tbl.GetFields(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public)) { + if (field.IsDefined(typeof(DoNotStoreAttribute))) + continue; + bool nullable = field.IsDefined(typeof(NullableAttribute)); + Type pt = field.FieldType; + decimal length = 0; + string defaultValue = null; + // Convert nullable types to their base type, but set nullable flag + if (pt == typeof(bool?)) { + pt = typeof(bool); + nullable = true; + } else if (pt == typeof(int?)) { + pt = typeof(int); + nullable = true; + } else if (pt == typeof(decimal?)) { + pt = typeof(decimal); + nullable = true; + } else if (pt == typeof(double?)) { + pt = typeof(double); + nullable = true; + } else if (pt == typeof(DateTime?)) { + pt = typeof(DateTime); + nullable = true; + } + PrimaryAttribute pk = field.GetCustomAttribute(); + if (pk != null) + nullable = false; // Primary keys may not be null + // Set length and default value (may be overridden later by specific attributes) + if (pt == typeof(bool)) { + length = 1; + defaultValue = "0"; + } else if (pt == typeof(int)) { + length = 11; + defaultValue = "0"; + } else if (pt == typeof(decimal)) { + length = 10.2M; + defaultValue = "0.00"; + } else if (pt == typeof(double)) { + length = 10.4M; + defaultValue = "0"; + } else if (pt == typeof(string)) { + length = 45; + defaultValue = ""; + } + if (nullable) + defaultValue = null; // If field is nullable, null is always the default value + LengthAttribute la = field.GetCustomAttribute(); + if (la != null) // Override length + length = la.Length + la.Precision / 10M; + DefaultValueAttribute da = field.GetCustomAttribute(); + if (da != null) // Override default value + defaultValue = da.Value; + Field fld = new Field(field.Name, pt, length, nullable, pk != null && pk.AutoIncrement, defaultValue); + if (pk != null) { + primary.Add(new Tuple(pk.Sequence, fld)); + primaryName = pk.Name; + } + // See if the field is in one or more indexes + foreach (UniqueAttribute a in field.GetCustomAttributes()) { + List> index; + if (!indexes.TryGetValue(a.Name, out index)) { + // New index + index = new List>(); + indexes[a.Name] = index; + } + // Add field to index + index.Add(new Tuple(a.Sequence, fld)); + } + // See if the field is a foreign key + ForeignKeyAttribute fk = field.GetCustomAttribute(); + if (fk != null) + _foreignKeys[fld] = fk; + fields.Add(fld); + } + if (primary.Count == 0) { + // No primary key - use the first field + primary.Add(new Tuple(0, fields[0])); + primaryName = "PRIMARY"; + } + // Build the index list + List inds = new List(indexes.Keys + .OrderBy(k => k) // In name order + .Select(k => new Index(k, indexes[k] + .OrderBy(i => i.Item1) // Sequence + .Select(i => i.Item2) // Field name + .ToArray()))); + // Primary key is always first index. + inds.Insert(0, new Index(primaryName, primary + .OrderBy(i => i.Item1) + .Select(i => i.Item2) + .ToArray())); + if (view != null) { + Table updateTable = null; + // Update table is all text after last "_" + _tables.TryGetValue(Regex.Replace(tbl.Name, "^.*_", ""), out updateTable); + _tables[tbl.Name] = new View(tbl.Name, fields.ToArray(), inds.ToArray(), view.Sql, updateTable); + } else { + _tables[tbl.Name] = new Table(tbl.Name, fields.ToArray(), inds.ToArray()); + } + } + + } + +} diff --git a/Database.cs b/Database.cs new file mode 100644 index 0000000..acd7343 --- /dev/null +++ b/Database.cs @@ -0,0 +1,1451 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Data; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Types of audit record + /// + public enum AuditType { + Insert = 1, + Update, + Previous, // The old version of the record on updates + Delete, + Reconcile + }; + /// + /// Account Types - these correspond to records in the AccountType table + /// + public enum AcctType { + Income = 1, + Expense, + Security, + OtherIncome, + OtherExpense, + FixedAsset, + OtherAsset, + AccountsReceivable, + Bank, + Investment, + OtherCurrentAsset, + CreditCard, + AccountsPayable, + OtherCurrentLiability, + LongTermLiability, + OtherLiability, + Equity + } + /// + /// Predefined accounts - these correspond to records in the Account table + /// + public enum Acct { + SalesLedger = 1, + PurchaseLedger, + OpeningBalEquity, + RetainedEarnings, + ShareCapital, + UndepositedFunds, + UninvoicedSales, + VATControl + } + /// + /// Document Types - these correspond to records in the DocumentType table + /// + public enum DocType { + Invoice = 1, + Payment, + CreditMemo, + Bill, + BillPayment, + Credit, + Cheque, + Deposit, + CreditCardCharge, + CreditCardCredit, + GeneralJournal, + Transfer, + OpeningBalance, + Buy, + Sell, + Gain + } + /// + /// For database logging + /// + public enum LogLevel { + None = 0, + Writes, + Reads + }; + /// + /// Class for accessing the database + /// + public class Database : IDisposable { + public const int CurrentDbVersion = 2; + DbInterface _db; + /// + /// Data dictionary + /// + static Dictionary _tables; + + /// + /// Upgrade a table to correspond to the class in the code + /// + /// Table info from code + /// Table info from database (or null, if none) + static void Upgrade(DbInterface db, Table code, Table database) { + if (database == null) { + db.CreateTable(code); + return; + } + bool view = code is View; + if (view != database is View) { + // Class has switched from View to a Table, or vice versa + db.DropTable(database); + db.CreateTable(code); + return; + } + if (view) { + bool? result = db.ViewsMatch(code as View, database as View); + if (result == false) { + db.DropTable(database); + db.CreateTable(code); + } + return; + } + // Lists of changes + List insert = new List(); + List update = new List(); + List remove = new List(); + List insertFK = new List(); + List dropFK = new List(); + List insertIndex = new List(); + List dropIndex = new List(); + // Check all code fields are in database + foreach (Field f1 in code.Fields) { + Field f2 = database.FieldFor(f1.Name); + if (f2 == null) + insert.Add(f1); + else { + if (!db.FieldsMatch(code, f1, f2)) { + update.Add(f1); + } + if (f1.ForeignKey == null) { + if (f2.ForeignKey != null) + dropFK.Add(f2); + } else { + if (f2.ForeignKey == null) + insertFK.Add(f1); + else if (f1.ForeignKey.Table.Name != f2.ForeignKey.Table.Name) { + dropFK.Add(f2); + insertFK.Add(f1); + } + } + } + } + // Remove any database fields not in code + foreach (Field f2 in database.Fields) { + if (code.FieldFor(f2.Name) == null) { + remove.Add(f2); + if (f2.ForeignKey != null) + dropFK.Add(f2); + } + } + // Check all code indexes are in database + foreach (Index i1 in code.Indexes) { + Index i2 = database.Indexes.Where(i => i.FieldList == i1.FieldList).FirstOrDefault(); + if (i2 == null) { + insertIndex.Add(i1); + } + } + // Remove any indexes not in code + foreach (Index i2 in database.Indexes) { + if (code.Indexes.Where(i => i.FieldList == i2.FieldList).FirstOrDefault() == null) + dropIndex.Add(i2); + } + if (view) { + if (insert.Count == 0 && update.Count == 0 && remove.Count == 0) + return; + // View has changed - recreate it + db.DropTable(database); + db.CreateTable(code); + return; + } + if (insert.Count != 0 || update.Count != 0 || remove.Count != 0 || insertFK.Count != 0 || dropFK.Count != 0 || insertIndex.Count != 0 || dropIndex.Count != 0) { + // Table has changed - upgrade it + db.UpgradeTable(code, database, insert, update, remove, insertFK, dropFK, insertIndex, dropIndex); + if (insert.Count != 0 || update.Count != 0) { + // Fill any new/changed fields with their default values + foreach (Field f in insert.Concat(update).Where(f => !string.IsNullOrEmpty(f.DefaultValue))) { + int lastInsertId; + db.Execute(string.Format("UPDATE {0} SET {1} = {2} WHERE {1} IS NULL", + code.Name, f.Name, f.Quote(f.DefaultValue)), out lastInsertId); + } + } + } + } + + /// + /// Upgrade the database to match the code + /// + static void Upgrade(DbInterface db) { + Dictionary dbTables = db.Tables(); + CodeFirst code = new CodeFirst(); + _tables = code.Tables; + // Process the tables in order to avoid foreign key violations + TableList orderedTables = new TableList(_tables.Values); + foreach (Table t in orderedTables.Reverse()) { + Table database; + dbTables.TryGetValue(t.Name, out database); + Upgrade(db, t, database); + } + + } + + /// + /// Static constructor upgrades database to match code, then does any coded updates. + /// + static Database() { + try { + using (DbInterface dbi = getDatabase(AppSettings.Default.ConnectionString)) { + Upgrade(dbi); + using (Database db = new Database(dbi)) { + db.BeginTransaction(); + db.Upgrade(); + db.Commit(); + } + } + } catch (Exception ex) { + WebServer.Log(ex.ToString()); + throw; + } + } + + /// + /// Coded updates - make sure all required records exist, etc. + /// + public void Upgrade() { + Table table = TableFor("Settings"); + if (!RecordExists(table, 1)) { + Update("Settings", new JObject().AddRange("idSettings", 1, + "YearStartMonth", 1, + "YearStartDay", 0, + "DbVersion", CurrentDbVersion)); + } + LogLevel originalLevel = Logging; + Settings settings = Get(1); + if (settings.DbVersion < CurrentDbVersion && Logging < LogLevel.Writes) + Logging = LogLevel.Writes; + table = TableFor("AccountType"); + ensureRecordExists(table, AcctType.Income, + "Negate", true, "Heading", "Gross Profit", "BalanceSheet", false); + ensureRecordExists(table, AcctType.Expense, + "Negate", false, "Heading", "Gross Profit", "BalanceSheet", false); + ensureRecordExists(table, AcctType.Security, + "Negate", true, "Heading", "Net Profit", "BalanceSheet", false); + ensureRecordExists(table, AcctType.OtherIncome, + "Negate", true, "Heading", "Net Profit", "BalanceSheet", false); + ensureRecordExists(table, AcctType.OtherExpense, + "Negate", false, "Heading", "Net Profit", "BalanceSheet", false); + ensureRecordExists(table, AcctType.FixedAsset, + "Negate", false, "Heading", "Fixed Assets", "BalanceSheet", true); + ensureRecordExists(table, AcctType.OtherAsset, + "Negate", false, "Heading", "Fixed Assets", "BalanceSheet", true); + ensureRecordExists(table, AcctType.AccountsReceivable, + "Negate", false, "Heading", "Current Assets", "BalanceSheet", true); + ensureRecordExists(table, AcctType.Bank, + "Negate", false, "Heading", "Current Assets", "BalanceSheet", true); + ensureRecordExists(table, AcctType.Investment, + "Negate", false, "Heading", "Current Assets", "BalanceSheet", true); + ensureRecordExists(table, AcctType.OtherCurrentAsset, + "Negate", false, "Heading", "Current Assets", "BalanceSheet", true); + ensureRecordExists(table, AcctType.CreditCard, + "Negate", true, "Heading", "Current Liabilities", "BalanceSheet", true); + ensureRecordExists(table, AcctType.AccountsPayable, + "Negate", true, "Heading", "Current Liabilities", "BalanceSheet", true); + ensureRecordExists(table, AcctType.OtherCurrentLiability, + "Negate", true, "Heading", "Current Liabilities", "BalanceSheet", true); + ensureRecordExists(table, AcctType.LongTermLiability, + "Negate", true, "Heading", "Long Term and Other Liabilities", "BalanceSheet", true); + ensureRecordExists(table, AcctType.OtherLiability, + "Negate", true, "Heading", "Long Term and Other Liabilities", "BalanceSheet", true); + ensureRecordExists(table, AcctType.Equity, + "Negate", true, "Heading", "Equities", "BalanceSheet", true); + ensureDocTypeExists(DocType.Invoice, "C", (int)Acct.SalesLedger); + ensureDocTypeExists(DocType.Payment, "C", (int)Acct.SalesLedger); + ensureDocTypeExists(DocType.CreditMemo, "C", (int)Acct.SalesLedger); + ensureDocTypeExists(DocType.Bill, "S", (int)Acct.PurchaseLedger); + ensureDocTypeExists(DocType.BillPayment, "S", (int)Acct.PurchaseLedger); + ensureDocTypeExists(DocType.Credit, "S", (int)Acct.PurchaseLedger); + ensureDocTypeExists(DocType.Cheque, "O", null); + ensureDocTypeExists(DocType.Deposit, "O", null); + ensureDocTypeExists(DocType.CreditCardCharge, "O", null); + ensureDocTypeExists(DocType.CreditCardCredit, "O", null); + ensureDocTypeExists(DocType.GeneralJournal, "O", null); + ensureDocTypeExists(DocType.Transfer, "O", null); + ensureDocTypeExists(DocType.OpeningBalance, "O", null); + ensureDocTypeExists(DocType.Buy, "O", null); + ensureDocTypeExists(DocType.Sell, "O", null); + ensureDocTypeExists(DocType.Gain, "O", null); + switch (settings.DbVersion) { + case 0: + case 1: + // Version 2 introduced some new account types + Execute("UPDATE Account SET AccountTypeId = AccountTypeId + 1 WHERE AccountTypeId >= 3"); + Execute("UPDATE Account SET AccountTypeId = AccountTypeId + 1 WHERE AccountTypeId >= 10"); + goto case 2; // We have just upgraded to version 2 + case 2: + break; + default: + throw new CheckException("Database has more recent version {0} than program {1}", + settings.DbVersion, CurrentDbVersion); + } + if (settings.DbVersion < CurrentDbVersion) + Execute("UPDATE Settings SET DbVersion = " + CurrentDbVersion); + Logging = originalLevel; + table = TableFor("Account"); + ensureRecordExists(table, Acct.SalesLedger, + "AccountTypeId", AcctType.AccountsReceivable); + ensureRecordExists(table, Acct.PurchaseLedger, + "AccountTypeId", AcctType.AccountsPayable); + ensureRecordExists(table, Acct.OpeningBalEquity, + "AccountTypeId", AcctType.Equity); + ensureRecordExists(table, Acct.RetainedEarnings, + "AccountTypeId", AcctType.Equity, + "Protected", true); + ensureRecordExists(table, Acct.ShareCapital, + "AccountTypeId", AcctType.Equity); + ensureRecordExists(table, Acct.UndepositedFunds, + "AccountTypeId", AcctType.OtherCurrentAsset); + ensureRecordExists(table, Acct.UninvoicedSales, + "AccountTypeId", AcctType.OtherCurrentAsset); + ensureRecordExists(table, Acct.VATControl, + "AccountTypeId", AcctType.OtherCurrentLiability, + "AccountDescription", "VAT to pay/receive"); + table = TableFor("NameAddress"); + if (!RecordExists(table, 1)) { + JObject d = new JObject().AddRange("idNameAddress", 1, + "Type", "O", + "Name", ""); + update(table, d, false); + } + } + + /// + /// Ensure a record matching an enum value exists + /// + /// Additional field values: name, value, name, value, ... + void ensureRecordExists(Table table, object enumValue, params object [] args) { + JObject d = new JObject().AddRange(table.PrimaryKey.Name, (int)enumValue, + table.Indexes[1].Fields[0].Name, enumValue.UnCamel()); + d.AddRange(args); + updateIfChanged(table, d); + } + + /// + /// Ensure a record matching a doc type exists + /// + void ensureDocTypeExists(DocType enumValue, string nameType, int? primaryAccountid) { + Table table = TableFor("DocumentType"); + JObject d = new JObject().AddRange(table.PrimaryKey.Name, (int)enumValue, + table.Indexes[1].Fields[0].Name, enumValue.UnCamel(), + "NameType", nameType, + "Sign", AppModule.SignFor(enumValue), + "PrimaryAccountId", primaryAccountid); + updateIfChanged(table, d); + } + + /// + /// Get a DbInterface to talk to the type of database in use (SQLite or MySql at present) + /// + static DbInterface getDatabase(string connectionString) { + switch(AppSettings.Default.Database.ToLower()) { + case "sqlite": + return new SQLiteDatabase(connectionString); + case "mysql": + return new MySqlDatabase(connectionString); + default: + throw new CheckException("Unknown database type {0}", AppSettings.Default.Database); + } + } + + public Database() + : this(AppSettings.Default.ConnectionString) { + } + + public Database(string connectionString) { + _db = getDatabase(connectionString); + } + + Database(DbInterface db) { + _db = db; + } + + /// + /// Write an update transaction pair or other type of transaction to the audit trail + /// + public void Audit(AuditType type, string table, int? id, string transaction, string previous) { + if (transaction == previous) + return; + Utils.Check(id != null, "Attempt to audit null record id"); + int lastInsertId; + string date = Utils.Now.ToString("yyyy-MM-dd HH:mm:ss"); + execute("INSERT INTO AuditTrail (TableName, ChangeType, DateChanged, RecordId, Record) VALUES(" + + Quote(table) + ", " + (int)type + ", " + Quote(date) + ", " + id + ", " + Quote(transaction) + ")", out lastInsertId); + if(!string.IsNullOrEmpty(previous)) + execute("INSERT INTO AuditTrail (TableName, ChangeType, DateChanged, RecordId, Record) VALUES(" + + Quote(table) + ", " + (int)AuditType.Previous + ", " + Quote(date) + ", " + id + ", " + Quote(previous) + ")", out lastInsertId); + } + + /// + /// Write a transaction to the audit trail + /// + public void Audit(AuditType type, string table, int? id, JObject transaction) { + Audit(type, table, id, transaction.ToString(), null); + } + + /// + /// Write an update transaction pair, or an insert transaction to the audit trail + /// + public void AuditUpdate(string table, int? id, JObject oldTransaction, JObject newTransaction) { + if (oldTransaction == null) { + Audit(AuditType.Insert, table, id, newTransaction.ToString(), null); + } else { + Audit(AuditType.Update, table, id, newTransaction.ToString(), oldTransaction.ToString()); + } + } + + public void BeginTransaction() { + _db.BeginTransaction(); + } + + /// + /// Return SQL to cast a value to a type (database dependent) + /// + public string Cast(string value, string type) { + return _db.Cast(value, type); + } + + /// + /// Check a field name is valid (i.e. all letters) + /// + static public void CheckValidFieldname(string f) { + if (!IsValidFieldname(f)) + throw new CheckException("'{0}' is not a valid field name", f); + } + + /// + /// Clean up the database (database dependent) + /// + public void Clean() { + _db.CleanDatabase(); + } + + /// + /// Commit current transaction + /// + public void Commit() { + _db.Commit(); + } + + /// + /// Delete the supplied record from the supplied table. + /// Data must contain at least a unique key for the table. + /// + public void Delete(string tableName, JObject data) { + delete(TableFor(tableName), data); + } + + /// + /// Delete the record from the table, optionally record in audit trail. + /// + public void Delete(string tableName, int id, bool withAudit) { + Table t = TableFor(tableName); + if (withAudit) { + JObject old = withAudit ? QueryOne("+", "WHERE " + t.PrimaryKey.Name + '=' + id, tableName) : null; + if (old != null && !old.IsAllNull()) { + Execute("DELETE FROM " + tableName + " WHERE " + t.PrimaryKey.Name + '=' + id); + Audit(AuditType.Delete, tableName, id, old); + } + } else { + Execute("DELETE FROM " + tableName + " WHERE " + t.PrimaryKey.Name + '=' + id); + } + } + + /// + /// Delete the supplied record from its table. + /// Data must contain at least a unique key for the table. + /// + public void Delete(JsonObject data) { + delete(TableFor(data.GetType()).UpdateTable, data.ToJObject()); + } + + public void delete(Table table, JObject data) { + Index index = table.IndexFor(data); + Utils.Check(index != null, "Deleting from {0}:data does not specify unique record", table.Name); + Execute("DELETE FROM " + table.Name + " WHERE " + index.Where(data)); + } + + /// + /// Close database, rolling back any uncommitted transaction. + /// + public void Dispose() { + Rollback(); + _db.Dispose(); + _db = null; + } + + /// + /// Return an empty record for the table. + /// All fields will have their default value. + /// + static public JObject EmptyRecord(string tableName) { + return emptyRecord(TableFor(tableName)); + } + + /// + /// Return an empty C# object of the type. + /// All fields will have their default value. + /// + static public T EmptyRecord() where T : JsonObject { + JObject record = emptyRecord(TableFor(typeof(T))); + return record.ToObject(); + } + + static JObject emptyRecord(Table table) { + JObject record = new JObject(); + foreach (Field field in table.Fields.Where(f => f.ForeignKey == null && !f.Nullable)) { + record[field.Name] = field.Type == typeof(string) ? "" : Activator.CreateInstance(field.Type).ToJToken(); + } + record[table.PrimaryKey.Name] = null; + return record; + } + + /// + /// Execute some Sql on the database. + /// + public int Execute(string sql) { + int lastInsertId; + return execute(sql, out lastInsertId); + } + + /// + /// Execute some sql, and return the id of any inserted record. + /// + int execute(string sql, out int lastInserttId) { + using (new Timer(sql)) { + if (Logging >= LogLevel.Writes) Log(sql); + try { + return _db.Execute(sql, out lastInserttId); + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + } + + /// + /// Determine if there is a record on the table with the given id. + /// + public bool Exists(string tableName, int? id) { + Table table = TableFor(tableName); + string idName = table.PrimaryKey.Name; + return id != null && QueryOne("SELECT " + idName + " FROM " + tableName + " WHERE " + + idName + " = " + id) != null; + } + + /// + /// Return the id of the record in tableName matching data. + /// If there is no such record, create one using data as the field values. + /// + public int? ForeignKey(string tableName, JObject data) { + int? result = LookupKey(tableName, data); + return result ?? insert(TableFor(tableName), data); + } + + /// + /// Return the id of the record in tableName matching data. + /// If there is no such record, create one using data as the field values. + /// Of the form: name, value, ... + /// + public int? ForeignKey(string tableName, params object[] data) { + return ForeignKey(tableName, new JObject().AddRange(data)); + } + + /// + /// Get the record with the given id, as a C# object. + /// + public T Get(int id) where T : JsonObject { + Table table = TableFor(typeof(T)); + return QueryOne("SELECT * FROM " + table.Name + " WHERE " + table.PrimaryKey.Name + " = " + id); + } + + /// + /// Get the record with a unique key matching criteria, as a C# object. + /// + public T Get(T criteria) where T : JsonObject { + Table table = TableFor(typeof(T)); + JObject data = criteria.ToJObject(); + Index index = table.IndexFor(data); + if (index != null) { + data = QueryOne("SELECT * FROM " + table.Name + " WHERE " + index.Where(data)); + } else { + data = null; + } + if (data == null || data.IsAllNull()) + data = emptyRecord(table); + return data.ToObject(); + } + + /// + /// Get the record from the given table, with the given id, as a JObject. + /// + public JObject Get(string tableName, int id) { + Table table = TableFor(tableName); + JObject result = QueryOne("SELECT * FROM " + table.Name + " WHERE " + table.PrimaryKey.Name + " = " + id); + return result == null ? emptyRecord(table) : result; + } + + /// + /// Produce an "IN(...)" SQL statement from a list of values + /// + public static string In(params object[] args) { + return "IN(" + string.Join(",", args.Select(o => Quote(o is Enum ? (int)o : o)).ToArray()) + ")"; + } + + /// + /// Produce an "IN(...)" SQL statement from a list of values + /// + public static string In(IEnumerable args) { + return "IN(" + string.Join(",", args.Select(o => Quote(o)).ToArray()) + ")"; + } + + /// + /// Insert a new record in the given table. + /// On return, data's Id field will be filled in. + /// + public void Insert(string tableName, JObject data) { + insert(TableFor(tableName), data); + } + + /// + /// Insert a new record in the given table. + /// On return, data's Id field will be filled in. + /// + public void Insert(string tableName, JsonObject data) { + Table table = TableFor(tableName); + JObject d = data.ToJObject(); + insert(table, d); + data.Id = (int)d[table.PrimaryKey.Name]; + } + + /// + /// Insert a C# object as a new record. + /// On return, data's Id field will be filled in. + /// + public void Insert(JsonObject data) { + Table table = TableFor(data.GetType()).UpdateTable; + JObject d = data.ToJObject(); + insert(table, d); + data.Id = (int)d[table.PrimaryKey.Name]; + } + + int insert(Table table, JObject data) { + Field idField = table.PrimaryKey; + string idName = idField.Name; + List fields = table.Fields.Where(f => data[f.Name] != null).ToList(); + checkForMissingFields(table, data, true); + try { + int lastInsertId; + execute("INSERT INTO " + table.Name + " (" + + string.Join(", ", fields.Select(f => f.Name).ToArray()) + ") VALUES (" + + string.Join(", ", fields.Select(f => f.Quote(data[f.Name])).ToArray()) + ")", out lastInsertId); + data[idName] = lastInsertId; + return lastInsertId; + } catch (DatabaseException ex) { + throw new DatabaseException(ex, table); + } + } + + void checkForMissingFields(Table table, JObject data, bool insert) { + Field idField = table.PrimaryKey; + string[] errors = table.Indexes.SelectMany(i => i.Fields) + .Distinct() + .Where(f => f != idField && !f.Nullable && string.IsNullOrWhiteSpace(data.AsString(f.Name)) && (insert || data[f.Name] != null)) + .Select(f => f.Name) + .ToArray(); + Utils.Check(errors.Length == 0, "Table {0} {1}:Missing key fields {2}", + table.Name, insert ? "insert" : "update", string.Join(", ", errors)); + } + + /// + /// Check a field name is all letters. + /// + /// + /// + static public bool IsValidFieldname(string f) { + return Regex.IsMatch(f, @"^[a-z]+$", RegexOptions.IgnoreCase); + } + + public void Log(string sql) { + WebServer.Log(sql); + } + + public LogLevel Logging; + + + /// + /// Return the id of the record in tableName matching data. + /// If there is no such record, return null. + /// + public int? LookupKey(string tableName, JObject data) { + Table table = TableFor(tableName); + string idName = table.PrimaryKey.Name; + Index index = table.IndexFor(data); + if (index == null || index.Fields.FirstOrDefault(f => data[f.Name].ToString() != "") == null) return null; + JObject result = QueryOne("SELECT " + idName + " FROM " + tableName + " WHERE " + + index.Where(data)); + return result == null ? null : result[idName].To(); + } + + /// + /// Return the id of the record in tableName matching data. + /// If there is no such record, return null. + /// Of the form: name, value, ... + /// + public int? LookupKey(string tableName, params object[] data) { + return LookupKey(tableName, new JObject().AddRange(data)); + } + + /// + /// Fill in the "next" and "previous" variables in record with the next and previous + /// document ids. + /// + /// Sql to add to document select to limit the documents returned, + /// e.g. to the next cheque from this bank account. + public void NextPreviousDocument(JObject record, string sql) { + JObject header = (JObject)record["header"]; + int id = header.AsInt("idDocument"); + string d = Quote(header.AsDate("DocumentDate")); + JObject next = id == 0 ? null : QueryOne("SELECT idDocument FROM Document " + sql + + " AND (DocumentDate > " + d + " OR (DocumentDate = " + d + " AND idDocument > " + id + "))" + + " ORDER BY DocumentDate, idDocument"); + record["next"] = next == null ? 0 : next.AsInt("idDocument"); + JObject previous = QueryOne("SELECT idDocument FROM Document " + sql + + (id == 0 ? "" : " AND (DocumentDate < " + d + " OR (DocumentDate = " + d + " AND idDocument < " + id + "))") + + " ORDER BY DocumentDate DESC, idDocument DESC"); + record["previous"] = previous == null ? 0 : previous.AsInt("idDocument"); + } + + /// + /// Run any sql query. + /// + public JObjectEnumerable Query(string sql) { + if (Logging >= LogLevel.Reads) Log(sql); + try { + using (new Timer(sql)) { + return new JObjectEnumerable(_db.Query(sql)); + } + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + + /// + /// Run an sql query, returning the specified fields. + /// Creates an automatic join for any foreign keys. + /// + /// Fields to return, null, empty or "+" means return all fields from all tables, but with + /// the fields from the first index on foreign key master tables added (+) or replacing the foreign key id (null or empty). + /// To limit the records returned. + /// Table names to query - joins will be constructed between them if there is more than 1. + public JObjectEnumerable Query(string fields, string conditions, params string[] tableNames) { + return Query(buildQuery(fields, conditions, tableNames)); + } + + string buildQuery(string fields, string conditions, params string[] tableNames) { + List joins = new List(); + List
tables = tableNames.Select(n => Database.TableFor(n)).ToList(); + List allFields = new List(); // Field list to use if fields is null, empty or "+" + List
processed = new List
(); + foreach (Table q in tables) { + processed.Add(q); + Field pk = q.PrimaryKey; + if (joins.Count == 0) { + joins.Add("FROM " + q.Name); + allFields.AddRange(q.Fields); + } else { + Table detail = processed.FirstOrDefault(t => t.ForeignKeyFieldFor(q) != null); + if (detail != null) { + // q is master to a table already processed - add a join from detail to master + Field fk = detail.ForeignKeyFieldFor(q); + joins.Add("LEFT JOIN " + q.Name + " ON " + q.Name + "." + fk.ForeignKey.Field.Name + " = " + detail.Name + "." + fk.Name); + allFields.AddRange(q.Fields.Where(f => f != pk)); + } else { + // q is detail, hopefully to a master already processed + Table master = processed.FirstOrDefault(t => q.ForeignKeyFieldFor(t) != null); + if (master == null) + throw new CheckException("No joins between {0} and any of {1}", + q.Name, string.Join(",", processed.Select(t => t.Name).ToArray())); + // Add a join from master to detail + Field fk = q.ForeignKeyFieldFor(master); + joins.Add("LEFT JOIN " + q.Name + " ON " + q.Name + "." + fk.Name + " = " + master.Name + "." + fk.ForeignKey.Field.Name); + allFields.AddRange(q.Fields.Where(f => f != pk)); + } + } + // Now create joins for any foreign keys which are for other tables (not in tableNames) + foreach (Field fk in q.Fields.Where(f => f.ForeignKey != null && f.ForeignKey.Table.Indexes.Length > 1 && tables.IndexOf(f.ForeignKey.Table) < 0)) { + Table master = fk.ForeignKey.Table; + string joinName = q.Name + "_" + master.Name; + joins.Add("LEFT JOIN " + master.Name + " AS " + joinName + " ON " + joinName + "." + fk.ForeignKey.Field.Name + " = " + q.Name + "." + fk.Name); + int i = allFields.IndexOf(fk); + if (i <= 0) // Do not remove first field, which will be key of first file + i = allFields.Count; + else if(fields != "+") + allFields.RemoveAt(i); + allFields.InsertRange(i, master.Indexes[1].Fields); + } + } + if (string.IsNullOrEmpty(fields) || fields == "+") + fields = string.Join(",", allFields.Select(f => f.Name).ToArray()); + return "SELECT " + fields + "\r\n" + string.Join("\r\n", joins) + "\r\n" + conditions; + } + + /// + /// Run any sql query, returning C# objects. + /// + public IEnumerable Query(string sql) { + return Query(sql).Select(r => r.ToObject()); + } + + /// + /// Run an sql query, filling the returned C# objects from the specified fields. + /// Creates an automatic join for any foreign keys. + /// + /// Fields to return, null, empty or "+" means return all fields from all tables, but with + /// the fields from the first index on foreign key master tables added (+) or replacing the foreign key id (null or empty). + /// To limit the records returned. + /// Table names to query - joins will be constructed between them if there is more than 1. + public IEnumerable Query(string fields, string conditions, params string[] tableNames) { + return Query(fields, conditions, tableNames).Select(r => r.ToObject()); + } + + /// + /// Run any Sql query, returning the first matching record, or null if none. + /// + public JObject QueryOne(string query) { + return _db.QueryOne(query); + } + + /// + /// Run an sql query, returning the first matching record, or null if none. + /// Creates an automatic join for any foreign keys. + /// + /// Fields to return, null, empty or "+" means return all fields from all tables, but with + /// the fields from the first index on foreign key master tables added (+) or replacing the foreign key id (null or empty). + /// To limit the records returned. + /// Table names to query - joins will be constructed between them if there is more than 1. + public JObject QueryOne(string fields, string conditions, params string[] tableNames) { + return QueryOne(buildQuery(fields, conditions, tableNames)); + } + + /// + /// Run any sql query, filling the returned C# object from the first record, or an empty record if none. + /// + public T QueryOne(string query) where T : JsonObject { + JObject data = QueryOne(query); + return data == null || data.IsAllNull() ? EmptyRecord() : data.To(); + } + + /// + /// Run an sql query, filling the returned C# object from the specified fields in the first record, or an empty record if none. + /// Creates an automatic join for any foreign keys. + /// + /// Fields to return, null, empty or "+" means return all fields from all tables, but with + /// the fields from the first index on foreign key master tables added (+) or replacing the foreign key id (null or empty). + /// To limit the records returned. + /// Table names to query - joins will be constructed between them if there is more than 1. + public T QueryOne(string fields, string conditions, params string[] tableNames) where T : JsonObject { + JObject data = QueryOne(fields, conditions, tableNames); + return data == null || data.IsAllNull() ? EmptyRecord() : data.To(); + } + + /// + /// Quote a field for inclusion in an Sql statement + /// + static public string Quote(object o) { + if (o == null || o == DBNull.Value) return "NULL"; + if (o is int || o is long || o is double) return o.ToString(); + if (o is decimal) return ((decimal)o).ToString("0.00"); + if (o is double) return (Math.Round((decimal)o, 4)).ToString(); + if (o is double) return ((decimal)o).ToString("0"); + if (o is bool) return (bool)o ? "1" : "0"; + if(o is DateTime) return "'" + ((DateTime)o).ToString("yyyy-MM-dd") + "'"; + return "'" + o.ToString().Replace("'", "''") + "'"; + } + + /// + /// Test if a record with the specified id exists in the specified table. + /// + public bool RecordExists(string table, int id) { + return RecordExists(TableFor(table), id); + } + + /// + /// Test if a record with the specified id exists in the specified table. + /// + public bool RecordExists(Table table, int id) { + Field idField = table.PrimaryKey; + string idName = idField.Name; + return QueryOne("SELECT " + idName + " FROM " + table.Name + " WHERE " + idName + " = " + id) != null; + } + + /// + /// Rollback the current transaction. + /// + public void Rollback() { + _db.Rollback(); + } + + /// + /// List of all table names in the data dictionary. + /// + static public IEnumerable TableNames { + get { return _tables.Where(t => !t.Value.IsView).Select(t => t.Key); } + } + + /// + /// List of all view names in the data dictionary. + /// + static public IEnumerable ViewNames { + get { return _tables.Where(t => t.Value.IsView).Select(t => t.Key); } + } + + /// + /// Get the data dictionary info for a table name. + /// + static public Table TableFor(string name) { + Table table; + Utils.Check(_tables.TryGetValue(name, out table), "Table '{0}' does not exist", name); + return table; + } + + /// + /// Get the data dictionary info for a C# type. + /// + static public Table TableFor(Type type) { + Type t = type; + while (!_tables.ContainsKey(t.Name)) { + t = t.BaseType; + Utils.Check(t != typeof(JsonObject), "Unable to find a table for type {0}", type.Name); + } + return TableFor(t.Name); + } + + /// + /// Update the record uniquely identified by the data. + /// If there is no such record, insert one, and fill in the Id field. + /// + public void Update(string tableName, JObject data) { + update(TableFor(tableName), data, false); + } + + /// + /// Update the record uniquely identified by the data. + /// If there is no such record, insert one, and fill in the Id field. + /// + public void Update(JsonObject data) { + Update(data, false); + } + + /// + /// Update the record uniquely identified by the data. + /// If there is no such record, insert one, and fill in the Id field. + /// Optionally save an audit trail. + /// + public void Update(JsonObject data, bool withAudit) { + Table table = TableFor(data.GetType()).UpdateTable; + JObject d = data.ToJObject(); + update(table, d, withAudit); + data.Id = (int)d[table.PrimaryKey.Name]; + } + + void update(Table table, JObject data, bool withAudit) { + Field idField = table.PrimaryKey; + string idName = idField.Name; + JToken idValue = null; + // Find the first unique index we have data for. Will be Primary (Id) index if that is included. + Index index = table.IndexFor(data); + JObject result = null; + if (index != null) { + // Retrieve any existing record that matches the index. + // If auditing, get all the foreign key fields too. + result = QueryOne(withAudit ? "+" : idName, "WHERE " + index.Where(data), table.Name); + if (result != null) { + // Set the id field + data[idName] = idValue = result[idName]; + } + } + List fields = table.Fields.Where(f => data[f.Name] != null).ToList(); + checkForMissingFields(table, data, idValue == null); + try { + int id; + if (idValue != null) { + // It's an existing record - update all supplied fields + execute("UPDATE " + table.Name + " SET " + + string.Join(", ", fields.Where(f => f != idField).Select(f => f.Name + '=' + f.Quote(data[f.Name])).ToArray()) + + " WHERE " + index.Where(data), out id); + id = idValue.To(); + } else { + // It's a new record - insert it and record the inserted id + execute("INSERT INTO " + table.Name + " (" + + string.Join(", ", fields.Select(f => f.Name).ToArray()) + ") VALUES (" + + string.Join(", ", fields.Select(f => f.Quote(data[f.Name])).ToArray()) + ")", out id); + data[idName] = id; + } + if (withAudit) { + // Retrieve the new record with all foreign key fields + data = QueryOne("+", "WHERE " + idName + " = " + id, table.Name); + AuditUpdate(table.Name, id, result, data); + } + } catch (DatabaseException ex) { + throw new DatabaseException(ex, table); + } + } + + /// + /// Update a record only if it has changed (used by initial record existence checks) + /// + void updateIfChanged(Table table, JObject data) { + Field idField = table.PrimaryKey; + string idName = idField.Name; + JToken idValue = null; + List fields = table.Fields.Where(f => data[f.Name] != null).ToList(); + Index index = table.IndexFor(data); + JObject result = null; + try { + result = QueryOne("SELECT * FROM " + table.Name + " WHERE " + index.Where(data)); + if (result != null) + data[idName] = idValue = result[idName]; + int id; + if (idValue != null) { + fields = fields.Where(f => data.AsString(f.Name) != result.AsString(f.Name)).ToList(); + if (fields.Count == 0) + return; + execute("UPDATE " + table.Name + " SET " + + string.Join(", ", fields.Where(f => f != idField).Select(f => f.Name + '=' + f.Quote(data[f.Name])).ToArray()) + + " WHERE " + index.Where(data), out id); + id = idValue.To(); + } else { + execute("INSERT INTO " + table.Name + " (" + + string.Join(", ", fields.Select(f => f.Name).ToArray()) + ") VALUES (" + + string.Join(", ", fields.Select(f => f.Quote(data[f.Name])).ToArray()) + ")", out id); + data[idName] = id; + } + } catch (DatabaseException ex) { + throw new DatabaseException(ex, table); + } + } + + /// + /// For measuring query performance + /// + public class Timer : IDisposable { + DateTime _start; + string _message; + + public Timer(string message) { + _start = Utils.Now; + _message = message; + } + + public void Dispose() { + double elapsed = (Utils.Now - _start).TotalMilliseconds; + if (elapsed > MaxTime) + WebServer.Log("{0}:{1}", elapsed, _message); + } + + public double MaxTime = AppSettings.Default.SlowQuery; + } + + } + + /// + /// Data Dictionary information + /// + public class ForeignKey { + public ForeignKey(Table table, Field field) { + Table = table; + Field = field; + } + + public Table Table { get; private set; } + + public Field Field { get; private set; } + } + + /// + /// Data Dictionary information + /// + public class Field { + + public Field(string name) { + Name = name; + Type = typeof(string); + } + + public Field(string name, Type type, decimal length, bool nullable, bool autoIncrement, string defaultValue) { + Name = name; + Type = type; + Length = length; + Nullable = nullable; + AutoIncrement = autoIncrement; + if (type == typeof(decimal) && defaultValue != null) { + try { + defaultValue = decimal.Parse(defaultValue).ToString("0.####"); + } catch { + } + } + if (defaultValue == null && !nullable) { + if (type == typeof(bool) || type == typeof(int) || type == typeof(decimal) || type == typeof(double)) { + defaultValue = "0"; + } else if (type == typeof(string)) { + defaultValue = ""; + } + } + DefaultValue = defaultValue; + } + + /// + /// AutoIncrement primary key + /// + public bool AutoIncrement { get; private set; } + + public string DefaultValue { get; private set; } + + /// + /// This field points to a master record on another table + /// + public ForeignKey ForeignKey; + + /// + /// Set to 0 for a memo type string field (unlimited length). + /// Default for strings is 45 + /// + public decimal Length { get; private set; } + + public string Name { get; private set; } + + public bool Nullable { get; private set; } + + /// + /// Quote this field for inclusion in sql statements + /// + public string Quote(object o) { + if (o == null || o == DBNull.Value) return "NULL"; + if ((Type == typeof(int) || Type == typeof(decimal) || Type == typeof(double) || Type == typeof(DateTime)) && o.ToString() == "") return "NULL"; + try { + o = Convert.ChangeType(o.ToString(), Type); + } catch (Exception ex) { + throw new CheckException(ex, "Invalid value for {0} field {1} '{2}'", Type.Name, Name, o); + } + if (o is int || o is long || o is double) return o.ToString(); + if (o is decimal) return ((decimal)o).ToString("0.00"); + if (o is double) return (Math.Round((decimal)o, 4)).ToString(); + if (o is bool) return (bool)o ? "1" : "0"; + if (o is DateTime) return "'" + ((DateTime)o).ToString("yyyy-MM-dd") + "'"; + return "'" + o.ToString().Replace("'", "''") + "'"; + } + + /// + /// C# type for this field + /// + public Type Type { get; private set; } + + /// + /// C# type name for this field (e.g. "int?") + /// + public string TypeName { + get { + string name = Type.Name; + switch (name) { + case "Int32": + return Nullable ? "int?" : "int"; + case "Decimal": + return Nullable ? "decimal?" : "decimal"; + case "Double": + return Nullable ? "double?" : "double"; + case "Boolean": + return Nullable ? "bool?" : "bool"; + case "DateTime": + return Nullable ? "DateTime?" : "DateTime"; + case "String": + return "string"; + default: + return name; + } + } + } + + public override string ToString() { + return Name + "(" + TypeName + ")"; + } + } + + /// + /// Data Dictionary information + /// + public class Index { + + public Index(string name, params Field[] fields) { + Name = name; + Fields = fields; + } + + public Index(string name, params string[] fields) { + Name = name; + Fields = fields.Select(f => new Field(f)).ToArray(); + } + + /// + /// Whether the data has non-null values for all the fields in this index + /// + public bool CoversData(JObject data) { + return (Fields.Where(f => data[f.Name] == null || data[f.Name].Type == JTokenType.Null).FirstOrDefault() == null); + } + + /// + /// Field names separated by commas, for inclusion in Sql + /// + public string FieldList { + get { return string.Join(",", Fields.Select(f => f.ToString()).ToArray()); } + } + + public Field[] Fields { get; private set; } + + public string Name { get; private set; } + + /// + /// A SQL clause to select the record which matches the data + /// + /// + /// + public string Where(JObject data) { + return string.Join(" AND ", Fields.Select(f => f.Name + "=" + f.Quote(data[f.Name])).ToArray()); + } + + public override string ToString() { + return "I:" + Name + "=" + FieldList; + } + } + + /// + /// Data Dictionary information + /// + public class Table { + + /// + /// First index must be primary key + /// + public Table(string name, Field [] fields, Index[] indexes) { + Name = name; + Fields = fields; + Indexes = indexes; + } + + public Field[] Fields; + + /// + /// Find field by name - returns null if none. + /// + public Field FieldFor(string name) { + return Fields.FirstOrDefault(f => f.Name == name); + } + + /// + /// Field in this file which is a foreign key for table + /// + public Field ForeignKeyFieldFor(Table table) { + return Fields.FirstOrDefault(f => f.ForeignKey != null && f.ForeignKey.Table.Name == table.Name); + } + + public Index[] Indexes { get; private set; } + + /// + /// First index which covers supplied data (i.e. for which data has all non-null values) + /// + public Index IndexFor(JObject data) { + return Indexes.Where(i => i.CoversData(data)).FirstOrDefault(); + } + + public string Name { get; private set; } + + public Field PrimaryKey { + get { return Indexes[0].Fields[0]; } + } + + /// + /// The table to update when writing - this for Tables, but something else for Views + /// + public virtual Table UpdateTable { get { return this; } } + + public virtual bool IsView { get { return false; } } + + public override string ToString() { + return "T:" + string.Join(",", Fields.Select(f => f.ToString()).ToArray()) + "\r\n" + + string.Join("\r\n", Indexes.Select(i => i.ToString()).ToArray()); + } + } + + /// + /// Data Dictionary information + /// + public class View : Table { + Table _updateTable; + + public View(string name, Field[] fields, Index[] indexes, string sql, Table updateTable) : base(name, fields, indexes) { + Sql = sql; + _updateTable = updateTable; + } + + public string Sql { get; private set; } + + public override Table UpdateTable { get { return _updateTable; } } + + public override bool IsView { get { return true; } } + + } + + /// + /// IEnumerable easily convertable to JArray, and with ToString method for debugging. + /// + public class JObjectEnumerable : IEnumerable { + IEnumerable _e; + + public JObjectEnumerable(IEnumerable e) { + _e = e; + } + + public List ToList() { + List e = _e.ToList(); + _e = e; + return e; + } + + public override string ToString() { + return this.ToJson(); + } + + public IEnumerator GetEnumerator() { + return _e.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { + return _e.GetEnumerator(); + } + + static public implicit operator JArray(JObjectEnumerable o) { + JArray j = new JArray(); + foreach (JObject jo in o) { + j.Add(jo); + } + return j; + } + } + + /// + /// Base class for all the C# types which represent a record + /// + public class JsonObject { + + public JObject ToJObject() { + return JObject.FromObject(this); + } + + public T Clone() { + return this.ToJObject().To(); + } + + /// + /// The single unique Id field + /// + public virtual int? Id { + get { return null; } + set { } + } + + public override string ToString() { + return this.ToJson(); + } + + } + + /// + /// Sorted list of tables, with views first, then bottom level details, with master tables last. + /// Used to avoid creating foreign key conflicts. + /// + public class TableList : List
{ + List
_allTables; + + public TableList(IEnumerable
allTables) { + _allTables = allTables.ToList(); + foreach (Table t in _allTables.Where(t => t is View)) + add(t); + foreach (Table t in _allTables.Where(t => !(t is View))) + add(t); + } + + void add(Table table) { + if (IndexOf(table) >= 0) return; + foreach (Table detail in _allTables.Where(t => t.ForeignKeyFieldFor(table) != null)) { + add(detail); + } + if (table is View) { + foreach (Table detail in _allTables.Where(t => t is View && (t as View).Sql.Contains(table.Name))) { + add(detail); + } + + } + Add(table); + } + } + + /// + /// Database exception records table name and Sql for later logging. + /// + public class DatabaseException : Exception { + + public DatabaseException(DatabaseException ex, Table table) + : base(ex.InnerException.Message, ex.InnerException) { + Sql = ex.Sql; + Table = table.Name; + } + + public DatabaseException(Exception ex, string sql) + : base(ex.Message, ex) { + Sql = sql; + } + + public string Sql; + + public string Table; + + public override string Message { + get { + return Table == null ? base.Message : Table + ":" + base.Message; + } + } + + public override string ToString() { + return base.ToString() + "\r\nSQL:" + Sql; + + } + } + +} diff --git a/DbInterface.cs b/DbInterface.cs new file mode 100644 index 0000000..3e0b1bd --- /dev/null +++ b/DbInterface.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Data; +using MySql.Data; +using MySql.Data.MySqlClient; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Interface through which all interaction between Database and a database-specific implementation happens. + /// + interface DbInterface : IDisposable { + void BeginTransaction(); + + /// + /// Return SQL to cast a value to a type + /// + string Cast(string value, string type); + + /// + /// Clean up the database + /// + void CleanDatabase(); + + /// + /// Commit current transaction + /// + void Commit(); + + void CreateTable(Table t); + + void CreateIndex(Table t, Index index); + + void DropTable(Table t); + + void DropIndex(Table t, Index index); + + /// + /// Execute sql, returning id of any record inserted + /// + int Execute(string sql, out int lastInserttId); + + /// + /// Do the fields in code and database match (some implementations are case insensitive) + /// + bool FieldsMatch(Table t, Field code, Field database); + + IEnumerable Query(string sql); + + JObject QueryOne(string query); + + void Rollback(); + + Dictionary Tables(); + + void UpgradeTable(Table code, Table database, List insert, List update, List remove, + List insertFK, List dropFK, List insertIndex, List dropIndex); + + /// + /// Do the views in code and database match + /// + bool? ViewsMatch(View code, View database); + + } +} diff --git a/Importer.cs b/Importer.cs new file mode 100644 index 0000000..baa63b5 --- /dev/null +++ b/Importer.cs @@ -0,0 +1,661 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// File import + /// + public class Importer { + protected AppModule _module; + protected Table _table; + /// + /// For detecting duplicate keys + /// + protected HashSet _keys; + + public Importer(string name, string tableName, params ImportField [] fields) { + Name = name; + TableName = tableName; + Fields = fields; + foreach (ImportField f in fields) + f.Importer = this; + if (!string.IsNullOrEmpty(tableName)) + _table = Database.TableFor(tableName); + } + + /// + /// Expected date format, or null to use DateTime.Parse + /// + public string DateFormat; + + public ImportField[] Fields { get; private set; } + + /// + /// Set up import, import the data, commit to the database if no errors + /// + public void Import(CsvParser csv, AppModule module) { + lock (this) { + _module = module; + _keys = new HashSet(); + ImportData(csv); + _module.Database.Commit(); + } + } + + /// + /// Import the actual data + /// + public virtual void ImportData(CsvParser csv) { + if (_module.Batch != null) + _module.Batch.Status += TableName; + foreach (JObject dataIn in csv.Read()) { + ImportDataLine(dataIn); + } + } + + /// + /// Import 1 line of data + /// + public virtual void ImportDataLine(JObject dataIn) { + JObject dataOut = new JObject(); + foreach (ImportField field in Fields) { + if(!string.IsNullOrEmpty(field.OurName)) + dataOut[field.OurName] = field.Value(_module.Database, dataIn); + } + Update(dataOut); + } + + /// + /// Given a file, read its headers, and find a suitable importer for it + /// + public static Importer ImporterFor(CsvParser csv) { + return Importers.FirstOrDefault(i => i.Matches(csv)); + } + + /// + /// All the available importers + /// + public static Importer[] Importers = new Importer[] { + new IIFImporter(), + new Importer("Vat Code List", "VatCode", + new ImportField("Code", "VAT Code"), + new ImportField("VatDescription", "Description"), + new ImportRegex("Rate", "Rate", @"([0-9\.]*)") + ), + new Importer("Product List", "Product", + new ImportField("ProductName", "Item"), + new ImportField("ProductDescription", "Description"), + new ImportRegex("UnitPrice", "Price", @"([0-9\.]*)"), + new ImportForeignKey("VatCodeId", "Sales Tax Code", "VatCode", "Code"), + new ImportForeignKey("AccountId", "Account", "Account", "AccountName") + ), + new AccountsImporter("Account List", "Account", + new ImportField("AccountName", "Account"), + new ImportField("AccountDescription", "Description"), + new ImportForeignKey("AccountTypeId", "Type", "AccountType", "AcctType") + ), + new Importer("Customer List", "NameAddress", + new ImportFixed("Type", "C"), + new ImportField("Name", "Customer"), + new ImportField("Address", "Bill to"), + new ImportField("Telephone", "Phone"), + new ImportField("Email", "Email"), + new ImportField("Contact", "Contact") + ), + new Importer("Supplier List", "NameAddress", + new ImportFixed("Type", "S"), + new ImportField("Name", "Supplier"), + new ImportField("Address", "Address"), + new ImportField("Telephone", "Phone"), + new ImportField("Email", "Email"), + new ImportField("Contact", "Contact") + ), + new JournalImporter() + }; + + /// + /// Whether the csv file has all the fields required for this importer + /// + public bool Matches(CsvParser csv) { + HashSet fieldNames = new HashSet(csv.Headers); + foreach (ImportField f in Fields) { + if (f.TheirName != null && !fieldNames.Contains(f.TheirName)) { + System.Diagnostics.Trace.WriteLine("Does not match " + this.Name + " " + f.TheirName + " missing"); + return false; + } + } + return true; + } + + public string Name; + + public string TableName { get; protected set; } + + /// + /// Save an individual record to the database + /// + public virtual void Update(JObject dataOut) { + Index index = _table.IndexFor(dataOut); + if (index != null) { + string key = index.Where(dataOut); + Utils.Check(!_keys.Contains(key), "Duplicate key in import {0}", key); + _keys.Add(key); + } + _module.Database.Update(TableName, dataOut); + } + + /// + /// Special import for IIF files , which can contain multiple data sets + /// + public class IIFImporter : Importer { + /// + /// Importer for current data set + /// + Importer _importer; + /// + /// List of importers for all data sets + /// + Importer[] _importers; + + public IIFImporter() + : base("IIF Import File", "", new ImportField("", "!HDR")) { + _importers = new Importer[] { + new Importer("Vat Code List", "VatCode", + new ImportField("", "VATCODE"), + new ImportField("Code", "NAME"), + new ImportField("VatDescription", "DESC"), + new ImportRegex("Rate", "RATE", @"([0-9\.]*)") + ), + new Importer("Product List", "Product", + new ImportField("", "INVITEM"), + new ImportField("ProductName", "NAME"), + new ImportField("ProductDescription", "DESC"), + new ImportRegex("UnitPrice", "PRICE", @"([0-9\.]*)"), + new ImportForeignKey("VatCodeId", "VATCODE", "VatCode", "Code"), + new ImportForeignKey("AccountId", "ACCNT", "Account", "AccountName") + ), + new AccountsImporter("Account List", "Account", + new ImportField("", "ACCNT"), + new ImportField("AccountName", "NAME"), + new ImportField("AccountDescription", "DESC"), + new ImportACCNTTYPE() + ), + new Importer("Customer List", "NameAddress", + new ImportField("", "CUST"), + new ImportFixed("Type", "C"), + new ImportField("Name", "NAME"), + new ImportAddress("Address", "BADDR1", "BADDR2", "BADDR3", "BADDR4", "BADDR5"), + new ImportField("Telephone", "PHONE1"), + new ImportField("Email", "EMAIL"), + new ImportField("Contact", "CONT1") + ), + new Importer("Supplier List", "NameAddress", + new ImportField("", "VEND"), + new ImportFixed("Type", "S"), + new ImportField("Name", "NAME"), + new ImportAddress("Address", "ADDR1", "ADDR2", "ADDR3", "ADDR4", "ADDR5"), + new ImportField("Telephone", "PHONE1"), + new ImportField("Email", "EMAIL"), + new ImportField("Contact", "CONT1") + ), + }; + } + + /// + /// Look for "!" lines (which start a new data set), and import each data set + /// + public override void ImportData(CsvParser csv) { + _importer = null; + csv.PermitAnyFieldCount = true; + string delimiter = ""; + TableName = ""; + foreach (JObject dataIn in csv.Read()) { + string[] line = csv.Data; + if (line[0].StartsWith("!")) { + // New data set - remove the ! from the first field + line[0] = line[0].Substring(1); + // This will be the headers for the new import + csv.Headers = line; + _importer = _importers.FirstOrDefault(i => i.Matches(csv)); + if (_importer != null) { + // We want this data set - set up the importer, and tell the user + _importer._module = _module; + _importer._keys = new HashSet(); + _importer.DateFormat = DateFormat; + if (_module.Batch != null) { + _module.Batch.Status += delimiter + _importer.TableName; + } + TableName += delimiter + _importer.TableName; + delimiter = ","; + } + continue; + } + if (_importer != null) + _importer.ImportDataLine(dataIn); + } + } + + } + } + + /// + /// Special importer for QuickBooks transaction detail report + /// + public class JournalImporter : Importer { + int _tranId; + int _line; + int _vat; + decimal _vatAmount; + int _lastInvoiceNumber; + int _lastBillNumber; + int _lastJournalNumber; + Dictionary _lastChequeNumber; + Dictionary _lastDepositNumber; + + public JournalImporter() + : base("Transaction Detail Report", "Document", + new ImportField("idDocument", "Trans no"), + new ImportDocumentType("DocumentTypeId", "Type"), + new ImportField("DocumentDate", "Date"), + new ImportField("DocumentIdentifier", "Num"), + new ImportName("NameAddressId", "Name"), + new ImportField("Address1", "Address 1"), + new ImportField("Address2", "Address 2"), + new ImportField("Address3", "Address 3"), + new ImportField("Address4", "Address 4"), + new ImportField("Address5", "Address 5"), + new ImportField("DocumentMemo", "Memo"), + new ImportForeignKey("ProductId", "Item", "Product", "ProductName"), + new ImportAccount("AccountId", "Account"), + new ImportField("Cleared", "Clr"), + new ImportField("Outstanding", "Open Balance"), + new ImportField("Qty", "Qty"), + new ImportForeignKey("VatCodeId", "VAT Code", "VatCode", "Code"), + new ImportRegex("VatRate", "VAT Rate", @"([0-9\.]*)"), + new ImportField("VatAmount", "VAT Amount"), + new ImportField("Amount", "Amount") + ) { + _lastChequeNumber = new Dictionary(); + _lastDepositNumber = new Dictionary(); + } + + public override void ImportData(CsvParser csv) { + _tranId = 0; + _line = 0; + // Do the import + base.ImportData(csv); + // Now update the last cheque numbers, etc. + AppModule.AppSettings.RegisterNumber(_module, (int)DocType.Invoice, _lastInvoiceNumber); + AppModule.AppSettings.RegisterNumber(_module, (int)DocType.Bill, _lastBillNumber); + AppModule.AppSettings.RegisterNumber(_module, (int)DocType.GeneralJournal, _lastJournalNumber); + foreach (int k in _lastChequeNumber.Keys.Union(_lastDepositNumber.Keys)) { + FullAccount acct = _module.Database.Get(k); + int n; + bool save = false; + if (_lastChequeNumber.TryGetValue(k, out n)) + save |= acct.RegisterNumber(DocType.Cheque, n); + if (_lastDepositNumber.TryGetValue(k, out n)) + save |= acct.RegisterNumber(DocType.Deposit, n); + if (save) + _module.Database.Update(acct); + } + // Try to guess what VAT payments apply to each document + foreach(JObject vatPayment in new Select().VatPayments()) { + int id = vatPayment.AsInt("id"); + DateTime q = AppModule.AppSettings.QuarterStart(vatPayment.AsDate("value")); + _module.Database.Execute(@"UPDATE Document +JOIN Journal ON IdDocument = DocumentId +JOIN Line ON idLine = idJournal +SET VatPaid = " + id + @" +WHERE (DocumentTypeId IN (1, 3, 4, 6) OR Line.VatCodeId IS NOT NULL) +AND VatPaid = 0 +AND idDocument < " + id + @" +AND DocumentDate < " + Database.Quote(q)); + _module.Database.Execute("UPDATE Document SET VatPaid = " + id + " WHERE idDocument = " + id); + } + // Set the remainder to null (importer doesn't do nulls) + _module.Database.Execute(@"UPDATE Document SET VatPaid = NULL WHERE VatPaid = 0"); + } + + /// + /// Update a journal line + /// + public override void Update(JObject dataOut) { + if (dataOut["AccountId"] == null) return; // Can't post if no account + int id = dataOut.AsInt("idDocument"); + if (id == 0) + return; // Can't post if no document + bool newTran = id != _tranId; + DocType docType = (DocType)dataOut.AsInt("DocumentTypeId"); + if (newTran) { + // New document + _vat = 0; + _vatAmount = 0; + dataOut["DocumentAddress"] = string.Join("\r\n", Enumerable.Range(1, 5).Select(i => dataOut.AsString("Address" + i)).Where(s => !string.IsNullOrEmpty(s)).ToArray()); + if (!_module.Database.RecordExists("Document", dataOut.AsInt("idDocument"))) { + dataOut["VatPaid"] = 0; + } + base.Update(dataOut); // Save the document + _tranId = id; + _line = 0; + // Save the last invoice/cheque/etc. no + int number = Utils.ExtractNumber(dataOut.AsString("DocumentIdentifier")); + switch (docType) { + case DocType.Invoice: + case DocType.CreditMemo: + if (number > _lastInvoiceNumber) _lastInvoiceNumber = number; + break; + case DocType.Bill: + case DocType.Credit: + if (number > _lastBillNumber) _lastBillNumber = number; + break; + case DocType.Cheque: + case DocType.CreditCardCharge: + registerNumber(_lastChequeNumber, dataOut.AsInt("AccountId"), number); + break; + case DocType.Deposit: + case DocType.CreditCardCredit: + registerNumber(_lastDepositNumber, dataOut.AsInt("AccountId"), number); + break; + case DocType.GeneralJournal: + if (number > _lastJournalNumber) _lastJournalNumber = number; + break; + } + // Delete any existing lines + _module.Database.Execute("DELETE FROM Line WHERE idLine IN (SELECT idJournal FROM Journal WHERE DocumentId = " + _tranId + ")"); + _module.Database.Execute("DELETE FROM Journal WHERE DocumentId = " + _tranId); + } + dataOut["DocumentId"] = _tranId; + dataOut["JournalNum"] = ++_line; + dataOut["Memo"] = dataOut["DocumentMemo"]; + _module.Database.Update("Journal", dataOut); // Save the journal + if (dataOut.AsInt("AccountId") == (int)Acct.VATControl) { + // This is the VAT journal + _vatAmount += dataOut.AsDecimal("Amount"); + if (_vat != 0) { + // There is already a VAT journal - delete it + _module.Database.Execute("DELETE FROM Line WHERE idLine IN (SELECT idJournal FROM Journal WHERE DocumentId = " + _tranId + " AND JournalNum = " + _vat + ")"); + _module.Database.Execute("DELETE FROM Journal WHERE DocumentId = " + _tranId + " AND JournalNum = " + _vat); + _module.Database.Execute("UPDATE Journal SET JournalNum = JournalNum - 1 WHERE DocumentId = " + _tranId + " AND JournalNum > " + _vat); + // Bump this journal a line earlier + dataOut["JournalNum"] = --_line; + dataOut["Amount"] = _vatAmount; + _module.Database.Update("Journal", dataOut); + } + _vat = _line; // Remember, to avoid 2 VAT lines + } + // 2nd and subsequent journals (except VAT journal) have lines + // NB VAT Payments to HMRC have are exceptions - they have a line for the VAT payment + if (!newTran && (_line == 2 || dataOut.AsInt("AccountId") != (int)Acct.VATControl)) { + int sign = AppModule.SignFor(docType); + dataOut["idLine"] = dataOut["idJournal"]; + dataOut["LineAmount"] = sign * dataOut.AsDecimal("Amount"); + dataOut["Qty"] = sign * dataOut.AsDecimal("Qty"); + dataOut["VatRate"] = dataOut.AsDecimal("VatRate"); + dataOut["VatAmount"] = sign * dataOut.AsDecimal("VatAmount"); + _module.Database.Update("Line", dataOut); + } + } + + void registerNumber(Dictionary dict, int account, int number) { + if(number == 0) + return; + int current; + if (dict.TryGetValue(account, out current) && current >= number) + return; + dict[account] = number; + } + } + + /// + /// Importer for accounts - checks subaccount names for duplicates as well. + /// This is because the Quick Books transaction detail report does not give the full account name + /// + public class AccountsImporter : Importer { + public AccountsImporter(string name, string tableName, params ImportField[] fields) + : base(name, tableName, fields) { + } + + public override void Update(JObject dataOut) { + base.Update(dataOut); + string name = dataOut.AsString("AccountName"); + Utils.Check(!_keys.Contains(name), "Account {0} has duplicate name", name); + _keys.Add(name); + string [] parts = name.Split(':'); + if(parts.Length > 1) { + string key = parts[parts.Length - 1]; + Utils.Check(!_keys.Contains(key), "Subaccount of {0} has duplicate name {1}", name, key); + _keys.Add(key); + } + } + + } + + /// + /// A field to import + /// + public class ImportField { + + public ImportField(string ourName, string theirName) { + OurName = ourName; + TheirName = theirName; + } + + public Importer Importer; + + public string OurName { get; private set; } + + public string TheirName { get; private set; } + + public override string ToString() { + return base.ToString() + " " + TheirName + "=>" + OurName; + } + + public virtual JToken Value(Database db, JObject data) { + return data[TheirName]; + } + } + + /// + /// Parses dates according to DateFormat + /// + public class ImportDate : ImportField { + public ImportDate(string ourName, string theirName) + : base(ourName, theirName) { + } + + public override JToken Value(Database db, JObject data) { + string d = data.AsString(TheirName); + return string.IsNullOrWhiteSpace(d) ? (JToken)null : string.IsNullOrWhiteSpace(Importer.DateFormat) ? DateTime.Parse(d) : DateTime.ParseExact(d, Importer.DateFormat, System.Globalization.CultureInfo.InvariantCulture); + } + } + + /// + /// Import field with pattern to extract part of their value to use for our value + /// + public class ImportRegex : ImportField { + Regex _regex; + + public ImportRegex(string ourName, string theirName, string regex) : base(ourName, theirName) { + _regex = new Regex(regex, RegexOptions.Compiled); + } + + public override JToken Value(Database db, JObject data) { + return _regex.Match(data[TheirName].ToString()).Groups[1].Value; + } + } + + /// + /// Import field which is a key on another table + /// + public class ImportForeignKey : ImportField { + + public ImportForeignKey(string ourName, string theirName, string table, string foreignKey) + : base(ourName, theirName) { + Table = table; + ForeignKey = foreignKey; + } + + public string ForeignKey { get; private set; } + + public string Table { get; private set; } + + public override JToken Value(Database db, JObject data) { + JObject keyData = new JObject().AddRange(ForeignKey, base.Value(db, data)); + return db.ForeignKey(Table, keyData); + } + } + + /// + /// IIF import ACCNTTYPE recogniser + /// + public class ImportACCNTTYPE : ImportField { + public ImportACCNTTYPE() + : base("AccountTypeId", "ACCNTTYPE") { + } + + public override JToken Value(Database db, JObject data) { + string value = base.Value(db, data).ToString(); + switch (value) { + case "INC": value = "Income"; break; + case "EXP": value = "Expense"; break; + case "COGS": value = "Expense"; break; // ??? + case "EXINC": value = "Other Income"; break; + case "EXEXP": value = "Other Expense"; break; + case "FIXASSET": value = "Fixed Asset"; break; + case "OASSET": value = "Other Asset"; break; + case "AR": value = "Accounts Receivable"; break; + case "BANK": value = "Bank"; break; + case "OCASSET": value = "Other Current Asset"; break; + case "CCARD": value = "Credit Card"; break; + case "AP": value = "Accounts Payable"; break; + case "OCLIAB": value = "Other Current Liability"; break; + case "LTLIAB": value = "Long Term Liability"; break; + case "OLIAB": value = "Other Liability"; break; // No example found + case "EQUITY": value = "Equity"; break; + default: + throw new CheckException("Unknown account type {0}", value); + } + JObject keyData = new JObject().AddRange("AcctType", value); + return db.ForeignKey("AccountType", keyData); + } + } + + /// + /// Transaction detail report abbreviates DocType + /// + public class ImportDocumentType : ImportField { + public ImportDocumentType(string ourName, string theirName) + : base(ourName, theirName) { + } + + public override JToken Value(Database db, JObject data) { + string value = base.Value(db, data).ToString(); + if (value.IndexOf("Bill Pmt") == 0) + value = "Bill Payment"; + JObject keyData = new JObject().AddRange("DocType", value); + return db.ForeignKey("DocumentType", keyData); + } + } + + /// + /// Name types depend on what kind of record we are processing + /// + public class ImportName : ImportField { + public ImportName(string ourName, string theirName) + : base(ourName, theirName) { + } + + public override JToken Value(Database db, JObject data) { + string nameType; + switch (data.AsString("Account")) { + case "Purchase Ledger": + nameType = "S"; + break; + case "Sales Ledger": + nameType = "C"; + break; + default: + string type = data.AsString("Type"); + switch (type) { + case "Invoice": + case "Payment": + case "Credit Memo": + nameType = "C"; + break; + case "Bill": + case "Bill Pmt": + case "Credit": + nameType = "S"; + break; + default: + nameType = type.IndexOf("Bill Pmt") == 0 ? "S" : "O"; + break; + } + break; + } + JObject keyData = new JObject().AddRange("Type", nameType, "Name", base.Value(db, data)); + return db.ForeignKey("NameAddress", keyData); + } + } + + /// + /// Transaction detail report does not give full account name, just subaccount + /// + public class ImportAccount : ImportField { + Regex _r = new Regex(@"(.*?)( \([^)]*\))?$", RegexOptions.Compiled); + + public ImportAccount(string ourName, string theirName) + : base(ourName, theirName) { + } + + public override JToken Value(Database db, JObject data) { + Match m = _r.Match(base.Value(db, data).ToString()); + string ac = m.Groups[1].Value; + if (ac == "") return null; + JObject id = db.QueryOne("SELECT idAccount FROM Account WHERE AccountName = " + Database.Quote(ac)); + if (id != null) return id["idAccount"]; + id = db.QueryOne("SELECT idAccount FROM Account WHERE AccountName LIKE " + Database.Quote("%:" + ac)); + if (id != null) return id["idAccount"]; + throw new CheckException("Account '{0}' not found", ac); + } + } + + /// + /// This field always has the same value + /// + public class ImportFixed : ImportField { + JToken _value; + + public ImportFixed(string ourName, object value) + : base(ourName, null) { + _value = value.ToJToken(); + } + + public override JToken Value(Database db, JObject data) { + return _value; + } + } + + /// + /// IIF address import combines multiple fields into address + /// + public class ImportAddress : ImportField { + string[] _theirNames; + + public ImportAddress(string ourName, params string[] theirNames) + : base(ourName, theirNames[0]) { + _theirNames = theirNames; + } + + public override JToken Value(Database db, JObject data) { + return string.Join("\r\n", _theirNames.Select(n => data.AsString(n)).Where(d => !string.IsNullOrEmpty(d)).ToArray()); + } + } +} diff --git a/Investments.cs b/Investments.cs new file mode 100644 index 0000000..002c26f --- /dev/null +++ b/Investments.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + public class Investments : BankingAccounting { + public Investments() { + Menu = new MenuOption[] { + new MenuOption("Listing", "/investments/default.html"), + new MenuOption("Securities", "/investments/securities.html"), + new MenuOption("New Account", "/investments/detail.html?id=0") + }; + } + + /// + /// List all security accounts, with their cash balance and current security value + /// + public object DefaultListing() { + return Database.Query(@"SELECT Account.*, Amount AS CashBalance, Value +FROM Account +LEFT JOIN (SELECT AccountId, SUM(Amount) AS Amount FROM Journal GROUP BY AccountId) AS Balances ON Balances.AccountId = idAccount +JOIN AccountType ON idAccountType = AccountTypeId +LEFT JOIN (" + AccountValue(Utils.Today) + @") AS AccountValues ON AccountValues.ParentAccountId = Balances.AccountId +WHERE AccountTypeId = " + (int)AcctType.Investment + @" +GROUP BY idAccount ORDER BY AccountName"); + } + + /// + /// Retrieve a security account for editing + /// + public void Detail(int id) { + InvestmentDetail record = Database.QueryOne("Account.*, AcctType, SUM(Amount) AS CashBalance", + "WHERE idAccount = " + id, + "Account", "Journal"); + record.CurrentBalance = record.CashBalance - Database.QueryOne("SELECT SUM(Amount) AS Future FROM Journal JOIN Document ON idDocument = DocumentId WHERE AccountId = " + + id + " AND DocumentDate > " + Database.Quote(Utils.Today)).AsDecimal("Future"); + record.Value = Database.QueryOne("SELECT Value FROM (" + AccountValue(Utils.Today) + ") AS V WHERE ParentAccountid = " + id).AsDecimal("Value"); + if (record.Id == null) { + record.AccountTypeId = (int)AcctType.Investment; + } else { + checkAcctType(record.AccountTypeId, AcctType.Investment); + Title += " - " + record.AccountName; + } + Record = record; + } + + /// + /// List all transactions for account + /// + public IEnumerable DetailListing(int id) { + Extended_Document last = null; + int lastId = 0; + foreach (JObject l in Database.Query(@"SELECT Journal.idJournal, Document.*, NameAddress.Name As DocumentName, DocType, Journal.Cleared, Journal.Amount As DocumentAmount, AccountName As DocumentAccountName +FROM Journal +LEFT JOIN Document ON idDocument = Journal.DocumentId +LEFT JOIN DocumentType ON DocumentType.idDocumentType = Document.DocumentTypeId +LEFT JOIN NameAddress ON NameAddress.idNameAddress = Journal.NameAddressId +LEFT JOIN Journal AS J ON J.DocumentId = Journal.DocumentId AND J.AccountId <> Journal.AccountId +LEFT JOIN Account ON Account.idAccount = J.AccountId +WHERE Journal.AccountId = " + id + @" +ORDER BY DocumentDate DESC, idDocument DESC, J.JournalNum")) { + Extended_Document line = l.To(); + if (last != null) { + if (lastId == l.AsInt("idJournal")) { + if (last.DocumentTypeId != (int)DocType.Buy && last.DocumentTypeId != (int)DocType.Sell) + last.DocumentAccountName = "-split-"; + continue; + } + yield return last; + last = null; + } + last = line; + lastId = l.AsInt("idJournal"); + } + if (last != null) + yield return last; + } + + /// + /// Update account info after editing + /// + public AjaxReturn DetailPost(Account json) { + checkAcctType(json.AccountTypeId, AcctType.Investment); + return PostRecord(json, true); + } + + /// + /// Portfolio header - same as Detail header + /// + public void Portfolio(int id) { + Detail(id); + } + + /// + /// List all securities for account, with quantity and current value + /// + public IEnumerable PortfolioListing(int id) { + return (from p in Database.Query("SELECT SecurityName, AccountName, SV.* FROM (" + + SecurityValues(Utils.Today) + @") AS SV +JOIN Account ON idAccount = SV.AccountId +JOIN Security ON idSecurity = SecurityId +WHERE ParentAccountid = " + id) + let cb = SecurityCost(p.AsInt("AccountId")) + select new JObject(p).AddRange( + "CostBasis", cb, + "Change", cb == 0 ? 0 : 100 * (p.AsDecimal("Value") - cb) / cb + )); + } + + /// + /// Get a Buy or Sell document for editing + /// + public void Document(int id, DocType type) { + Title = Title.Replace("Document", type.UnCamel()); + InvestmentDocument header = getDocument(id); + if (header.idDocument == null) { + header.DocumentTypeId = (int)type; + header.DocType = type.UnCamel(); + header.DocumentDate = Utils.Today; + header.DocumentName = ""; + if (GetParameters["acct"].IsInteger()) { + FullAccount acct = Database.QueryOne("*", "WHERE idAccount = " + GetParameters["acct"], "Account"); + if (acct.idAccount != null) { + header.DocumentAccountId = (int)acct.idAccount; + header.DocumentAccountName = acct.AccountName; + header.FeeAccount = Database.QueryOne("SELECT idAccount FROM Account WHERE AccountName = " + Database.Quote(acct.AccountName + " fees")).AsInt("idAccount"); + } + } + } else { + checkDocType(header.DocumentTypeId, DocType.Buy, DocType.Sell); + List journals = Database.Query(@"SELECT * +FROM Journal +LEFT JOIN StockTransaction ON idStockTransaction = idJournal +LEFT JOIN Security ON idSecurity = SecurityId +WHERE JournalNum > 1 +AND DocumentId = " + id).ToList(); + header.SecurityId = journals[0].AsInt("SecurityId"); + header.SecurityName = journals[0].AsString("SecurityName"); + header.Quantity = journals[0].AsDouble("Quantity"); + header.Price = journals[0].AsDouble("Price"); + if (journals.Count > 1) { + header.FeeAccount = journals[1].AsInt("AccountId"); + header.Fee = journals[1].AsDecimal("Amount"); + header.FeeMemo = journals[1].AsString("Memo"); + } + if (type == DocType.Sell) + header.Quantity = -header.Quantity; + } + JObject record = new JObject().AddRange("header", header); + Database.NextPreviousDocument(record, "JOIN Journal ON DocumentId = idDocument WHERE DocumentTypeId " + + Database.In(DocType.Buy, DocType.Sell) + + (header.DocumentAccountId > 0 ? " AND AccountId = " + header.DocumentAccountId : "")); + Select s = new Select(); + record.AddRange("Accounts", s.ExpenseAccount(""), + "Names", s.Other(""), + "Securities", s.Security("")); + Record = record; + } + + public AjaxReturn DocumentDelete(int id) { + return deleteDocument(id, DocType.Buy, DocType.Sell, DocType.Transfer); + } + + /// + /// Update Buy/Sell after editing + /// + public AjaxReturn DocumentPost(InvestmentDocument json) { + Database.BeginTransaction(); + JObject oldDoc = getCompleteDocument(json.idDocument); + DocType t = checkDocType(json.DocumentTypeId, DocType.Buy, DocType.Sell); + FullAccount acct = Database.Get((int)json.DocumentAccountId); + checkAcctType(acct.AccountTypeId, AcctType.Investment); + int sign = SignFor(t); + fixNameAddress(json, "O"); + if(json.SecurityId == 0) { + Utils.Check(!string.IsNullOrEmpty(json.SecurityName), "No Security Name supplied"); + json.SecurityId = Database.ForeignKey("Security", "SecurityName", json.SecurityName); + } + if (string.IsNullOrEmpty(json.DocumentMemo)) + json.DocumentMemo = json.SecurityName; + if (json.idDocument == null) { + StockPrice p = Database.QueryOne("SELECT * FROM " + LatestPrice(json.DocumentDate) + " WHERE SecurityId = " + json.SecurityId); + if (p.Price != json.Price) { + // Stock price is different from file price, and its a new buy/sell - update file price for security date + p.SecurityId = (int)json.SecurityId; + p.Date = json.DocumentDate; + p.Price = json.Price; + Database.Update(p); + } + } + decimal cost = (decimal)Math.Round(json.Price * json.Quantity, 2); + decimal amount = json.Fee + sign * cost; + Database.Update(json); + // First journal is posting to this account + Journal journal = Database.Get(new Journal() { + DocumentId = (int)json.idDocument, + JournalNum = 1 + }); + journal.DocumentId = (int)json.idDocument; + journal.AccountId = json.DocumentAccountId; + journal.NameAddressId = json.DocumentNameAddressId; + journal.Memo = json.DocumentMemo; + journal.JournalNum = 1; + journal.Amount = -amount; + journal.Outstanding = -amount; + Database.Update(journal); + // Second journal is to subaccount for this security (account:security) + journal = Database.Get(new Journal() { + DocumentId = (int)json.idDocument, + JournalNum = 2 + }); + journal.DocumentId = (int)json.idDocument; + journal.AccountId = (int)Database.ForeignKey("Account", + "AccountName", acct.AccountName + ":" + json.SecurityName, + "AccountTypeId", (int)AcctType.Security); + journal.NameAddressId = json.DocumentNameAddressId; + journal.Memo = json.DocumentMemo; + journal.JournalNum = 2; + journal.Amount = journal.Outstanding = sign * cost; + Database.Update(journal); + // Corresponding line + Line line = Database.Get((int)journal.idJournal); + line.idLine = journal.idJournal; + line.LineAmount = cost; + Database.Update(line); + // Now update the stock transaction + StockTransaction st = Database.Get((int)journal.idJournal); + st.idStockTransaction = journal.idJournal; + st.ParentAccountId = json.DocumentAccountId; + st.SecurityId = (int)json.SecurityId; + st.Price = json.Price; + st.Quantity = sign * json.Quantity; + st.CostPer = Math.Round((double)amount / json.Quantity, 4); + Database.Update(st); + if(json.Fee != 0) { + // Need another journal and line for the fee + Utils.Check(json.FeeAccount > 0, "No Fee Account supplied"); + journal = Database.Get(new Journal() { + DocumentId = (int)json.idDocument, + JournalNum = 3 + }); + journal.DocumentId = (int)json.idDocument; + journal.AccountId = (int)json.FeeAccount; + journal.NameAddressId = json.DocumentNameAddressId; + journal.Memo = json.FeeMemo; + journal.JournalNum = 3; + journal.Amount = journal.Outstanding = json.Fee; + Database.Update(journal); + line = Database.Get((int)journal.idJournal); + line.idLine = journal.idJournal; + line.LineAmount = sign * json.Fee; + Database.Update(line); + } + // Delete any left over lines from the old transaction + Database.Execute("DELETE FROM Line WHERE idLine IN (SELECT idJournal FROM Journal WHERE DocumentId = " + json.idDocument + " AND JournalNum > " + journal.JournalNum + ")"); + Database.Execute("DELETE FROM Journal WHERE Documentid = " + json.idDocument + " AND JournalNum > " + journal.JournalNum); + // Audit + JObject newDoc = getCompleteDocument(json.idDocument); + Database.AuditUpdate("Document", json.idDocument, oldDoc, newDoc); + Database.Commit(); + return new AjaxReturn() { message = "Document saved", id = json.idDocument }; + } + + /// + /// A balance adjustment posts enough to to reach the new balance. + /// Important fields are ExistingBalance and NewBalance + /// + public void BalanceAdjustment(int id, int acct) { + checkAccountIsAcctType(acct, AcctType.Investment); + BalanceAdjustmentDocument doc = Database.Get(id); + doc.NewBalance = doc.ExistingBalance = Database.QueryOne(@"SELECT SUM(Amount) AS Amount FROM Journal WHERE AccountId = " + acct).AsDecimal("Amount"); + if (doc.idDocument == null) { + doc.DocumentAccountId = acct; + doc.DocumentDate = Utils.Today; + doc.DocumentMemo = "Balance Adjustment"; + if(string.IsNullOrEmpty(doc.DocumentIdentifier)) + doc.DocumentIdentifier = "Balance Adjustment"; + JObject o = Database.QueryOne(@"SELECT J.AccountId, AccountName +FROM Journal +JOIN Document ON idDocument = Journal.DocumentId +JOIN Journal AS J ON J.DocumentId = idDocument AND J.JournalNum = 2 +JOIN Account ON idAccount = J.AccountId +WHERE Journal.JournalNum = 1 +AND DocumentTypeID IN (" + (int)DocType.Cheque + "," + (int)DocType.Deposit + @") +AND Journal.AccountId = " + acct); + doc.AccountId = o.AsInt("AccountId"); + doc.AccountName = o.AsString("AccountName"); + doc.NameAddressId = 1; + doc.Name = ""; + } else { + checkDocType(doc.DocumentTypeId, DocType.Cheque, DocType.Deposit); + foreach (Journal j in Database.Query("SELECT * FROM Journal WHERE DocumentId = " + id)) { + switch (j.JournalNum) { + case 1: + doc.DocumentAccountId = j.AccountId; + doc.NameAddressId = j.NameAddressId; + doc.Amount = j.Amount; + break; + case 2: + doc.AccountId = j.AccountId; + break; + default: + throw new CheckException("Document is not a balance adjustment"); + } + } + Utils.Check(acct == doc.DocumentAccountId, "Document is for a different account"); + doc.Name = Database.QueryOne("SELECT Name FROM NameAddress WHERE idNameAddress = " + doc.NameAddressId).AsString("Name"); + doc.AccountName = Database.QueryOne("SELECT AccountName FROM Account WHERE idAccount = " + doc.AccountId).AsString("AccountName"); + doc.ExistingBalance -= doc.Amount; + } + Select s = new Select(); + Record = new JObject().AddRange( + "header", doc, + "Accounts", s.Account(""), + "Names", s.Other("")); + } + + /// + /// Post a BalanceAdjustment after editing. + /// Transaction amount is NewBalance - ExistingBalance + /// + public AjaxReturn BalanceAdjustmentPost(BalanceAdjustmentDocument json) { + checkAccountIsAcctType(json.DocumentAccountId, AcctType.Investment); + Utils.Check(json.AccountId > 0, "No account selected"); + // Pointless to post a new transaction that does nothing + Utils.Check(json.idDocument > 0 || json.NewBalance != json.ExistingBalance, "Balance is unchanged"); + if (json.NameAddressId == 0) + json.NameAddressId = Database.ForeignKey("NameAddress", + "Type", "O", + "Name", json.Name); + else + checkNameType(json.NameAddressId, "O"); + JObject old = getCompleteDocument(json.idDocument); + json.Amount = json.NewBalance - json.ExistingBalance; + json.DocumentTypeId = (int)(json.Amount < 0 ? DocType.Cheque : DocType.Deposit); + Database.BeginTransaction(); + Database.Update(json); + Journal j = new Journal(); + j.AccountId = json.DocumentAccountId; + j.Outstanding = j.Amount = json.Amount; + j.DocumentId = (int)json.idDocument; + j.JournalNum = 1; + j.Memo = json.DocumentMemo; + j.NameAddressId = json.NameAddressId; + Database.Update(j); + j = new Journal(); + j.AccountId = json.AccountId; + j.Outstanding = j.Amount = -json.Amount; + j.DocumentId = (int)json.idDocument; + j.JournalNum = 2; + j.Memo = json.DocumentMemo; + j.NameAddressId = json.NameAddressId; + Database.Update(j); + Line line = Database.Get((int)j.idJournal); + line.idLine = j.idJournal; + line.LineAmount = Math.Abs(json.Amount); + Database.Update(line); + JObject full = getCompleteDocument(json.idDocument); + Database.AuditUpdate("Document", json.idDocument, old, full); + Database.Commit(); + return new AjaxReturn() { message = "Balance adjusted", id = json.idDocument }; + } + + public void Securities() { + } + + public object SecuritiesListing() { + return Database.Query("SELECT * FROM Security ORDER BY SecurityName"); + } + + /// + /// Security header and stock prices + /// + public void Security(int id) { + Security record = Database.Get(id); + if (record.Id != null) + Title += " - " + record.SecurityName; + Record = new JObject().AddRange( + "header", record, + "detail", Database.Query("SELECT *, 7 AS Unit FROM StockPrice WHERE SecurityId = " + id + " ORDER BY Date DESC")); + } + + public AjaxReturn SecurityPost(SecurityInfo json) { + Security existing = Database.Get(json.header); + Database.BeginTransaction(); + Database.Update(json.header, true); + if (existing.idSecurity > 0 && json.header.SecurityName != existing.SecurityName) { + // Name has changed - change name of subaccounts + foreach(Account a in Database.Query("SELECT * FROM Account WHERE AccountName LIKE " + + Database.Quote("%:" + existing.SecurityName))) { + if(a.AccountName.EndsWith(":" + existing.SecurityName)) { + a.AccountName = a.AccountName.Substring(0, a.AccountName.Length - existing.SecurityName.Length) + json.header.SecurityName; + Database.Update(a); + } + } + } + // Replace old stock prices with new ones + Database.Execute("DELETE FROM StockPrice WHERE SecurityId = " + json.header.idSecurity); + foreach (StockPrice p in json.detail) { + Database.Insert(p); + } + Database.Commit(); + return new AjaxReturn() { message = "Security updated" }; + } + + /// + /// Sql to return the price of each stock as at date + /// + public static string LatestPrice(DateTime date) { + return string.Format(@"(select StockPrice.* FROM StockPrice JOIN +(select SecurityId AS Id, MAX(Date) AS MaxDate +FROM StockPrice +WHERE Date <= {0} +GROUP BY SecurityId) AS LatestPriceDate ON Id = SecurityId AND MaxDate = Date) AS LatestPrice", Database.Quote(date)); + } + + /// + /// Calculate the cost of all the securities in an account on a FIFO basis + /// + public decimal SecurityCost(int account) { + decimal cost = 0; + List transactions = Database.Query(@"SELECT StockTransaction.* +FROM StockTransaction +JOIN Journal ON Journal.idJournal = idStockTransaction +JOIN Document ON idDocument = Journal.DocumentId +WHERE AccountId = " + account + @" +ORDER BY DocumentDate, idDocument").ToList(); + foreach (CostedStockTransaction t in transactions) { + if (t.Quantity < 0) { + // Sold - work out the cost of the sold items + double q = -Math.Round(t.Quantity, 4); // Quantity sold + foreach (CostedStockTransaction b in transactions) { + if (b.Quantity > 0 && b.SecurityId == t.SecurityId) { + // Quantity bought + double qb = Math.Round(b.Quantity, 4); + // Amount sold, or quantity bought, whichever is the lesser + double qs = Math.Min(qb, q); + // Quantity left on purchase transaction + b.Quantity = Math.Round(qb - qs, 4); + q = Math.Round(q - qs, 4); + cost -= (decimal)Math.Round(b.CostPer * qs, 4); + if (q == 0) + break; + } + } + } else { + cost += (decimal)Math.Round(t.CostPer * t.Quantity, 4); + } + } + return Math.Round(cost, 2); + } + + /// + /// Sql to return the current value of each StockTransaction as at Date + /// + public static string SecurityValues(DateTime date) { + return @"SELECT ParentAccountId, AccountId, SecuritiesByAccount.SecurityId AS SecurityId, Quantity, Price, SUM(ROUND(Quantity * Price, 2)) AS Value +FROM (SELECT DocumentDate, ParentAccountId, AccountId, SecurityId, SUM(Quantity) AS Quantity +FROM StockTransaction +JOIN Journal ON idJournal = idStockTransaction +JOIN Document ON idDocument = DocumentId +WHERE DocumentDate <= " + Database.Quote(date) + @" +GROUP BY ParentAccountId, AccountId, SecurityId) AS SecuritiesByAccount +JOIN " + LatestPrice(date) + @" +ON LatestPrice.SecurityId = SecuritiesByAccount.SecurityId +GROUP BY AccountId, SecuritiesByAccount.SecurityId"; + } + + /// + /// Sql to return the current value of each security account as at fate + /// + public static string AccountValue(DateTime date) { + return @"SELECT ParentAccountId, SUM(Value) AS Value +FROM (" + SecurityValues(date) + @") AS SecurityValues +GROUP BY ParentAccountId"; + } + + public class InvestmentDetail : Account { + public decimal? CashBalance; + public decimal? CurrentBalance; + public decimal? Value; + } + + public class SecurityValue : JsonObject { + public int? ParentAccountId; + public int? AccountId; + public int? SecurityId; + public decimal Value; + } + + public class SecurityValueWithName : JsonObject { + public int? ParentAccountId; + public int? AccountId; + public int? SecurityId; + public string SecurityName; + public decimal Value; + } + + public class CostedStockTransaction : StockTransaction { + public decimal Cost; + } + + public class InvestmentDocument : Extended_Document { + public int? SecurityId; + public string SecurityName = ""; + public double Quantity; + public double Price; + public int? FeeAccount; + public decimal Fee; + public string FeeMemo = "Fees"; + } + + public class BalanceAdjustmentDocument : Document { + public int DocumentAccountId; + public int AccountId; + public string AccountName; + public int? NameAddressId; + public string Name; + public decimal Amount; + public decimal ExistingBalance; + public decimal NewBalance; + } + + public class SecurityInfo : JsonObject { + public Security header; + public List detail; + } + } +} diff --git a/JsonClasses.cs b/JsonClasses.cs new file mode 100644 index 0000000..d7c2b33 --- /dev/null +++ b/JsonClasses.cs @@ -0,0 +1,685 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Net; +using System.Net.Http; +using System.Web; +using System.IO; +using System.Reflection; +using Mustache; + +#pragma warning disable 0649 + +namespace AccountServer { + + [Table] + public class Account : JsonObject { + /// + /// For system accounts + /// + [Primary] + public int? idAccount; + /// + /// Account name. Subaccounts are stored as shown - e.g. "Payroll:Taxes". + /// + [Length(75)] + [Unique("AccountName_UNIQUE")] + public string AccountName; + [Length(75)] + public string AccountDescription; + /// + /// + /// + [ForeignKey("AccountType")] + public int AccountTypeId; + /// + /// Code to allow sorting in non-alphabetical order in reports - e.g. to sort in same order as your accountant uses + /// + public string AccountCode; + /// + /// System account, user cannot edit + /// + public bool Protected; + /// + /// Used only during reconciliation + /// + public decimal? EndingBalance; + public int NextChequeNumber; + public int NextDepositNumber; + /// + /// User does not want to see it + /// + public bool HideAccount; + /// + /// For matching statement data pasted from web + /// + [Length(0)] + public string StatementFormat; + public override int? Id { + get { return idAccount; } + set { idAccount = value; } + } + } + + [Table] + public class AccountType : JsonObject { + /// + /// + /// + [Primary] + public int? idAccountType; + /// + /// P&L or Balance sheet report heading + /// + [DefaultValue("Other")] + public string Heading; + [Unique("Name_UNIQUE")] + public string AcctType; + /// + /// Reverse the sign in P & L and Balance sheet + /// + public bool Negate; + /// + /// True if appears in Balance sheet report + /// + public bool BalanceSheet; + public override int? Id { + get { return idAccountType; } + set { idAccountType = value; } + } + } + + [Table] + public class AuditTrail : JsonObject { + [Primary] + public int? idAuditTrail; + public DateTime DateChanged; + public string TableName; + /// + /// + /// + public int ChangeType; + /// + /// Of the record being audited + /// + public int RecordId; + /// + /// Json data for the record + /// + [Length(0)] + public string Record; + public override int? Id { + get { return idAuditTrail; } + set { idAuditTrail = value; } + } + } + + [Table] + public class Document : JsonObject { + [Primary] + public int? idDocument; + [Length(0)] + public string DocumentMemo; + /// + /// + /// + [ForeignKey("DocumentType")] + public int DocumentTypeId; + [Length(0)] + public string DocumentAddress; + public DateTime DocumentDate; + public string DocumentIdentifier; + /// + /// Record of payment to HM which paid the vat in this document + /// + [DefaultValue("0")] + public int? VatPaid; + public override int? Id { + get { return idDocument; } + set { idDocument = value; } + } + } + + [Table] + public class DocumentType : JsonObject { + /// + /// + /// + [Primary] + public int? idDocumentType; + [Unique("DocumentName_UNIQUE")] + public string DocType; + /// + /// Id of Sales Ledger or Purchase Ledger account for invoices, payments, etc. Otherwise null + /// + [ForeignKey("Account")] + public int? PrimaryAccountId; + /// + /// "C" for Customer, "S" for Supplier, "O" for Other + /// + [Length(1)] + [DefaultValue("O")] + public string NameType; + /// + /// Natural document sign - e.g. -1 for deposits, 1 for cheques. + /// + /// + [DefaultValue("1")] + public int Sign; + public override int? Id { + get { return idDocumentType; } + set { idDocumentType = value; } + } + } + + [Table] + public class Journal : JsonObject { + [Primary] + public int? idJournal; + [ForeignKey("Document")] + [Unique("Document_Num")] + public int DocumentId; + [ForeignKey("Account")] + public int AccountId; + [Length(75)] + public string Memo; + [Unique("Document_Num", 1)] + public int JournalNum; + public decimal Amount; + /// + /// Unpaid amount for invoices, etc. + /// + public decimal Outstanding; + /// + /// "X" = cleared, "*" = marked for clearing in an incomplete reconciliation + /// + [Length(1)] + public string Cleared; + [ForeignKey("NameAddress")] + public int? NameAddressId; + public override int? Id { + get { return idJournal; } + set { idJournal = value; } + } + } + + [Table] + public class Line : JsonObject { + [ForeignKey("Journal")] + [Primary(AutoIncrement = false)] + public int? idLine; + public double Qty; + [ForeignKey("Product")] + public int? ProductId; + public decimal LineAmount; + [ForeignKey("VatCode")] + public int? VatCodeId; + public decimal VatRate; + public decimal VatAmount; + public override int? Id { + get { return idLine; } + set { idLine = value; } + } + } + + [Table] + public class NameAddress : JsonObject { + [Primary] + public int? idNameAddress; + /// + /// "C" for Customer, "S" for Supplier, "O" for Other + /// + [Length(1)] + [Unique("Type_Name")] + public string Type; + [Length(75)] + [Nullable] + [Unique("Type_Name", 1)] + public string Name; + [Length(0)] + public string Address; + [Length(15)] + public string PostCode; + public string Telephone; + [Length(50)] + public string Email; + public string Contact; + public bool Hidden; + public override int? Id { + get { return idNameAddress; } + set { idNameAddress = value; } + } + } + + /// + /// Cross-references Invoices to Payments at document level. + /// Implements a many-to-many relationship. + /// + [Table] + public class Payments : JsonObject { + [ForeignKey("Document")] + [Primary(AutoIncrement = false)] + public int? idPayment; + [ForeignKey("Document")] + [Primary(1, AutoIncrement = false)] + public int idPaid; + public decimal PaymentAmount; + public override int? Id { + get { return idPayment; } + set { idPayment = value; } + } + } + + [Table] + public class Product : JsonObject { + [Primary] + public int? idProduct; + [Length(75)] + [Unique("Name_UNIQUE")] + public string ProductName; + [Length(75)] + public string ProductDescription; + public decimal UnitPrice; + [ForeignKey("VatCode")] + public int? VatCodeId; + [ForeignKey("Account")] + public int AccountId; + /// + /// Input/display unit - these are implemented in default.js, + /// and include D:H:M and H:M + /// + public int Unit; + public override int? Id { + get { return idProduct; } + set { idProduct = value; } + } + } + + [Table] + public class Report : JsonObject { + [Primary] + public int? idReport; + [Length(75)] + [Unique("Name_Index", 1)] + public string ReportName; + [Length(25)] + public string ReportType; + /// + /// Json of report settings + /// + [Length(0)] + public string ReportSettings; + [Unique("Name_Index")] + public string ReportGroup; + public override int? Id { + get { return idReport; } + set { idReport = value; } + } + } + + [Table] + public class Security : JsonObject { + [Primary] + public int? idSecurity; + [Unique("Name_UNIQUE")] + public string SecurityName; + [Unique("Ticker_UNIQUE")] + public string Ticker; + public override int? Id { + get { return idSecurity; } + set { idSecurity = value; } + } + } + + [Table] + public class StockTransaction : JsonObject { + [ForeignKey("Journal")] + [Primary(AutoIncrement = false)] + public int? idStockTransaction; + [ForeignKey("Account")] + public int? ParentAccountId; + [ForeignKey("Security")] + public int SecurityId; + public double Quantity; + public double CostPer; + public double Price; + public override int? Id { + get { return idStockTransaction; } + set { idStockTransaction = value; } + } + } + + [Table] + public class StockPrice : JsonObject { + [Primary(AutoIncrement = false)] + [ForeignKey("Security")] + public int? SecurityId; + [Primary(1, AutoIncrement = false)] + public DateTime Date; + public double Price; + } + + [Table] + public class Schedule : JsonObject { + [Primary] + public int? idSchedule; + public DateTime ActionDate; + /// + /// + /// + public int RepeatType; + [Length(75)] + public string Task; + /// + /// Url where to post data for posts, or to redirect to + /// + [Nullable] + public string Url; + /// + /// Json of transaction to post + /// + [Nullable] + [Length(0)] + public string Parameters; + /// + /// True if transaction has to be posted + /// + public bool Post; + public override int? Id { + get { return idSchedule; } + set { idSchedule = value; } + } + } + + [Table] + public class Settings : JsonObject { + [Primary] + public int? idSettings; + [ForeignKey("Account")] + public int? DefaultBankAccount; + [Length(75)] + public string CompanyName; + [Length(0)] + public string CompanyAddress; + public string CompanyPhone; + [Length(50)] + public string CompanyEmail; + public string WebSite; + [Length(25)] + public string VatRegistration; + [Length(25)] + public string CompanyNumber; + [DefaultValue("1")] + public int YearStartMonth; + public int YearStartDay; + [DefaultValue("14")] + public int TermsDays; + [DefaultValue("1")] + public int NextInvoiceNumber; + [DefaultValue("1")] + public int NextBillNumber; + [DefaultValue("1")] + public int NextJournalNumber; + public int DatabaseLogging; + [DefaultValue("smtp.gmail.com")] + public string MailServer; + [DefaultValue("587")] + public int MailPort; + public bool MailSSL; + [Length(50)] + public string MailUserName; + public string MailPassword; + [DefaultValue("default")] + public string Skin; + [DefaultValue("2")] + public int DbVersion; + [DoNotStore] + public string AppVersion { + get { return Assembly.GetEntryAssembly().GetName().Version.ToString(); } + } + public override int? Id { + get { return idSettings; } + set { idSettings = value; } + } + + public int NextNumber(DocType docType) { + switch (docType) { + case DocType.Invoice: + case DocType.CreditMemo: + return NextInvoiceNumber; + case DocType.Bill: + case DocType.Credit: + return NextBillNumber; + case DocType.GeneralJournal: + return NextJournalNumber; + default: + return 1; + } + } + + public void RegisterNumber(AppModule module, int? docType, int current) { + switch (docType) { + case (int)DocType.Invoice: + case (int)DocType.CreditMemo: + registerNumber(module, ref NextInvoiceNumber, current); + break; + case (int)DocType.Bill: + case (int)DocType.Credit: + registerNumber(module, ref NextBillNumber, current); + break; + case (int)DocType.GeneralJournal: + registerNumber(module, ref NextJournalNumber, current); + break; + } + } + + void registerNumber(AppModule module, ref int next, int current) { + if (current >= next) { + next = current + 1; + write(module); + } + } + + public DateTime YearEnd(DateTime date) { + date = date.Date; + DateTime result = yearStart(date); + if (result <= date) + result = yearStart(result.AddMonths(13)); + return result.AddDays(-1); + } + + public DateTime YearStart(DateTime date) { + date = date.Date; + DateTime result = yearStart(date); + if (result > date) + result = yearStart(date.AddMonths(-12)); + return result; + } + + public DateTime QuarterStart(DateTime date) { + DateTime result = YearStart(date); + result = result.AddDays(1 - (int)result.Day); + for (DateTime end = result.AddMonths(3); end < date; end = result.AddMonths(3)) + result = end; + return result; + } + + void write(AppModule module) { + module.Database.Update(this); + } + + DateTime yearStart(DateTime date) { + int month = YearStartMonth; + if (month == 0) month = 1; + int day = YearStartDay; + // First day of the month + DateTime dayOfMonth = new DateTime(date.Year, month, 1); + if (day > 0) { + DayOfWeek dayOfWeek = (DayOfWeek)(day % 7); + + // Find first dayOfWeek of this month + if (dayOfMonth.DayOfWeek > dayOfWeek) { + dayOfMonth = dayOfMonth.AddDays(7 - (int)dayOfMonth.DayOfWeek + (int)dayOfWeek); + } else { + dayOfMonth = dayOfMonth.AddDays((int)dayOfWeek - (int)dayOfMonth.DayOfWeek); + } + } + return dayOfMonth; + } + } + + [Table] + public class VatCode : JsonObject { + [Primary] + public int? idVatCode; + [Length(25)] + [Unique("Name_UNIQUE")] + public string Code; + [Length(75)] + public string VatDescription; + public decimal Rate; + public override int? Id { + get { return idVatCode; } + set { idVatCode = value; } + } + } + + [View(@"SELECT idDocument, DocumentMemo, DocumentTypeId, DocType, Sign, +DocumentDate, Journal.NameAddressId AS DocumentNameAddressId, Name AS DocumentName, DocumentAddress, DocumentIdentifier, +Journal.Amount As AccountingAmount, Journal.Outstanding As AccountingOutstanding, +-Journal.Amount * DocumentType.Sign As DocumentAmount, -Journal.Outstanding * DocumentType.Sign As DocumentOutstanding, +Journal.AccountId AS DocumentAccountId, AccountName As DocumentAccountName, Journal.Cleared AS Clr, +VatJournal.Amount * DocumentType.Sign As DocumentVatAmount, VatPaid +FROM Document +JOIN DocumentType ON idDocumentType = DocumentTypeId +JOIN Journal ON Journal.DocumentId = idDocument AND Journal.JournalNum = 1 +JOIN Account ON idAccount = Journal.AccountId +JOIN NameAddress ON idNameAddress = Journal.NameAddressId +LEFT JOIN Journal AS VatJournal ON VatJournal.DocumentId = idDocument AND VatJournal.AccountId = 8 +")] + public class Extended_Document : JsonObject { + [Primary(AutoIncrement = false)] + public int? idDocument; + [Length(0)] + public string DocumentMemo; + public int DocumentTypeId; + public string DocType; + public int Sign; + public DateTime DocumentDate; + public int? DocumentNameAddressId; + [Length(75)] + public string DocumentName; + [Length(0)] + public string DocumentAddress; + public string DocumentIdentifier; + public decimal AccountingAmount; + public decimal AccountingOutstanding; + public decimal DocumentAmount; + public decimal DocumentOutstanding; + public int DocumentAccountId; + [Length(75)] + public string DocumentAccountName; + [Length(1)] + public string Clr; + public decimal? DocumentVatAmount; + public int? VatPaid; + public override int? Id { + get { return idDocument; } + set { idDocument = value; } + } + } + + [View(@"SELECT Line.*, Product.ProductName, Product.UnitPrice, VatCode.Code, VatCode.VatDescription, +Journal.DocumentId, Journal.JournalNum, Journal.Memo, Journal.AccountId, Account.AccountName, Account.AccountDescription +FROM Line +JOIN Product ON Product.idProduct = Line.ProductId +JOIN VatCode ON VatCode.idVatCode = Line.VatCodeId +JOIN Journal ON Journal.idJournal = Line.idLine +JOIN Account ON Account.idAccount = Journal.AccountId +")] + public class Extended_Line : JsonObject { + [Primary(AutoIncrement = false)] + public int? idLine; + public double Qty; + public int? ProductId; + public decimal LineAmount; + public int? VatCodeId; + public decimal VatRate; + public decimal VatAmount; + [Length(75)] + public string ProductName; + public decimal UnitPrice; + [Length(25)] + public string Code; + [Length(75)] + public string VatDescription; + public int DocumentId; + public int JournalNum; + [Length(75)] + public string Memo; + public int AccountId; + [Length(75)] + public string AccountName; + [Length(75)] + public string AccountDescription; + public override int? Id { + get { return idLine; } + set { idLine = value; } + } + } + + [View(@"SELECT Extended_Document.*, CASE DocumentAccountId WHEN 1 THEN -1 ELSE 1 END AS VatType, +Memo,Line.VatCodeId, Line.VatRate, SUM(Line.VatAmount) VatAmount, SUM(Line.LineAmount) AS LineAmount +FROM Extended_Document +JOIN Journal ON IdDocument = DocumentId +JOIN Line ON idLine = idJournal +WHERE DocumentTypeId IN (1, 3, 4, 6) +OR Line.VatCodeId IS NOT NULL +GROUP BY idDocument, Line.VatCodeId, Line.VatRate +")] + public class Vat_Journal : JsonObject { + [Primary(AutoIncrement = false)] + public int? idDocument; + [Length(0)] + public string DocumentMemo; + public int DocumentTypeId; + public string DocType; + public int Sign; + public DateTime DocumentDate; + public int? DocumentNameAddressId; + [Length(75)] + public string DocumentName; + [Length(0)] + public string DocumentAddress; + public string DocumentIdentifier; + public decimal AccountingAmount; + public decimal AccountingOutstanding; + public decimal DocumentAmount; + public decimal DocumentOutstanding; + public int DocumentAccountId; + public string DocumentAccountName; + [Length(1)] + public string Clr; + public decimal? DocumentVatAmount; + public int? VatPaid; + [Length(2)] + public int VatType; + [Length(75)] + public string Memo; + public int? VatCodeId; + public decimal VatRate; + public decimal? VatAmount; + public decimal? LineAmount; + public override int? Id { + get { return idDocument; } + set { idDocument = value; } + } + } + +#pragma warning restore 0649 +} + diff --git a/MySqlDatabase.cs b/MySqlDatabase.cs new file mode 100644 index 0000000..3d560fa --- /dev/null +++ b/MySqlDatabase.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Data; +using System.Data.Common; +using MySql.Data; +using MySql.Data.MySqlClient; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Interface to MySql + /// + public class MySqlDatabase : DbInterface { + MySqlConnection _conn; + MySqlTransaction _tran; + + public MySqlDatabase(string connectionString) { + _conn = new MySqlConnection(); + _conn.ConnectionString = connectionString; + _conn.Open(); + } + + public void BeginTransaction() { + if (_tran == null) + _tran = _conn.BeginTransaction(); + } + + /// + /// Return SQL to cast a value to a type + /// + public string Cast(string value, string type) { + return string.Format("CAST({0} AS {1})", value, type); + } + + public void CleanDatabase() { + foreach (string table in Database.TableNames) { + Execute("ALTER TABLE " + table + " AUTO_INCREMENT = 1"); + Execute("OPTIMIZE TABLE " + table); + } + } + + public void CreateTable(Table t) { + View v = t as View; + if (v != null) { + executeLog(string.Format("CREATE VIEW `{0}` AS {1}", v.Name, v.Sql)); + return; + } + List defs = new List(t.Fields.Select(f => fieldDef(f))); + for (int i = 0; i < t.Indexes.Length; i++) { + Index index = t.Indexes[i]; + if (i == 0) + defs.Add(string.Format("PRIMARY KEY ({0})", string.Join(",", index.Fields.Select(f => "`" + f.Name + "`").ToArray()))); + else + defs.Add(string.Format("UNIQUE INDEX `{0}` ({1})", index.Name, + string.Join(",", index.Fields.Select(f => "`" + f.Name + "` ASC").ToArray()))); + } + defs.AddRange(t.Fields.Where(f => f.ForeignKey != null).Select(f => string.Format(@"CONSTRAINT `fk_{0}_{1}_{2}` + FOREIGN KEY (`{2}`) + REFERENCES `{1}` (`{3}`) + ON DELETE NO ACTION + ON UPDATE NO ACTION", t.Name, f.ForeignKey.Table.Name, f.Name, f.ForeignKey.Table.PrimaryKey.Name))); + defs.AddRange(t.Fields.Where(f => f.ForeignKey != null && t.Indexes.FirstOrDefault(i => i.Fields[0] == f) == null).Select(f => string.Format(@"INDEX `fk_{0}_{1}_{2}_idx` (`{2}` ASC)", + t.Name, f.ForeignKey.Table.Name, f.Name))); + executeLog(string.Format("CREATE TABLE `{0}` ({1}) ENGINE=InnoDB", t.Name, string.Join(",\r\n", defs.ToArray()))); + } + + public void CreateIndex(Table t, Index index) { + executeLog(string.Format("ALTER TABLE `{0}` ADD UNIQUE INDEX `{1}` ({2})", t.Name, index.Name, + string.Join(",", index.Fields.Select(f => "`" + f.Name + "` ASC").ToArray()))); + } + + public void Commit() { + if (_tran != null) { + _tran.Commit(); + _tran.Dispose(); + _tran = null; + } + } + + public void Dispose() { + Rollback(); + if (_conn != null) { + _conn.Dispose(); + _conn = null; + } + } + + public void DropTable(Table t) { + executeLogSafe("DROP TABLE IF EXISTS " + t.Name); + executeLogSafe("DROP VIEW IF EXISTS " + t.Name); + } + + public void DropIndex(Table t, Index index) { + executeLogSafe(string.Format("ALTER TABLE `{0}` DROP INDEX `{1}`", t.Name, index.Name)); + } + + int Execute(string sql) { + int lastInserttId; + return Execute(sql, out lastInserttId); + } + + public int Execute(string sql, out int lastInserttId) { + using (MySqlCommand cmd = command(sql)) { + var ret = cmd.ExecuteNonQuery(); + lastInserttId = (int)cmd.LastInsertedId; + return ret; + } + } + + public bool FieldsMatch(Table t, Field code, Field database) { + if (code.TypeName != database.TypeName) return false; + if (t.IsView) return true; // Database does not always give correct values for view columns + if (code.AutoIncrement != database.AutoIncrement) return false; + if (code.Length != database.Length) return false; + if (code.Nullable != database.Nullable) return false; + // NB: MySql cannot show the difference between null and empty string default values! + if(code.TypeName == "string" && string.IsNullOrEmpty(code.DefaultValue) && string.IsNullOrEmpty(database.DefaultValue)) return true; + if (code.DefaultValue != database.DefaultValue) return false; + return true; + } + + public IEnumerable Query(string query) { + using (MySqlCommand cmd = command(query)) { + using (MySqlDataReader r = executeReader(cmd, query)) { + JObject row; + while ((row = readRow(r, query)) != null) { + yield return row; + } + } + } + } + + public JObject QueryOne(string query) { + return Query(query + " LIMIT 1").FirstOrDefault(); + } + + static public string Quote(object o) { + if (o == null || o == DBNull.Value) return "NULL"; + if (o is int || o is long || o is double) return o.ToString(); + if (o is decimal) return ((decimal)o).ToString("0.00"); + if (o is double) return (Math.Round((decimal)o, 4)).ToString(); + if (o is double) return ((decimal)o).ToString("0"); + if (o is bool) return (bool)o ? "1" : "0"; + if (o is DateTime) return "'" + ((DateTime)o).ToString("yyyy-MM-dd") + "'"; + return "'" + o.ToString().Replace("'", "''") + "'"; + } + + public void Rollback() { + if (_tran != null) { + _tran.Rollback(); + _tran.Dispose(); + _tran = null; + } + } + + public Dictionary Tables() { + // NB: By default. MySql table names are case insensitive + Dictionary tables = new Dictionary(StringComparer.OrdinalIgnoreCase); + string schema = Regex.Match(AppSettings.Default.ConnectionString, "database=(.*?);").Groups[1].Value; + using(MySqlConnection conn = new MySqlConnection(AppSettings.Default.ConnectionString)) { + conn.Open(); + DataTable tabs = conn.GetSchema("Tables"); + DataTable cols = conn.GetSchema("Columns"); + DataTable fkeyCols = conn.GetSchema("Foreign Key Columns"); + DataTable indexes = conn.GetSchema("Indexes"); + DataTable indexCols = conn.GetSchema("IndexColumns"); + DataTable views = conn.GetSchema("Views"); + DataTable viewCols = conn.GetSchema("ViewColumns"); + foreach(DataRow table in tabs.Rows) { + string name = table["TABLE_NAME"].ToString(); + string filter = "TABLE_NAME = " + Quote(name); + Field[] fields = cols.Select(filter, "ORDINAL_POSITION") + .Select(c => new Field(c["COLUMN_NAME"].ToString(), typeFor(c["DATA_TYPE"].ToString()), + lengthFromColumn(c), c["IS_NULLABLE"].ToString() == "YES", c["EXTRA"].ToString().Contains("auto_increment"), + c["COLUMN_DEFAULT"] == System.DBNull.Value ? null : c["COLUMN_DEFAULT"].ToString())).ToArray(); + List tableIndexes = new List(); + foreach (DataRow ind in indexes.Select(filter + " AND PRIMARY = 'True'")) { + string indexName = ind["INDEX_NAME"].ToString(); + tableIndexes.Add(new Index("PRIMARY", + indexCols.Select(filter + " AND INDEX_NAME = " + Quote(indexName), "ORDINAL_POSITION") + .Select(r => fields.First(f => f.Name == r["COLUMN_NAME"].ToString())).ToArray())); + } + foreach (DataRow ind in indexes.Select(filter + " AND PRIMARY = 'False' AND UNIQUE = 'True'")) { + string indexName = ind["INDEX_NAME"].ToString(); + tableIndexes.Add(new Index(indexName, + indexCols.Select(filter + " AND INDEX_NAME = " + Quote(indexName), "ORDINAL_POSITION") + .Select(r => fields.First(f => f.Name == r["COLUMN_NAME"].ToString())).ToArray())); + } + tables[name] = new Table(name, fields, tableIndexes.ToArray()); + } + foreach (DataRow fk in fkeyCols.Rows) { + // MySql 5 incorrectly returns lower case table and field names here + Table detail = tables[fk["TABLE_NAME"].ToString()]; + Table master = tables[fk["REFERENCED_TABLE_NAME"].ToString()]; + Field masterField = FieldFor(master, fk["REFERENCED_COLUMN_NAME"].ToString()); + FieldFor(detail, fk["COLUMN_NAME"].ToString()).ForeignKey = new ForeignKey(master, masterField); + } + foreach (DataRow table in views.Select("TABLE_SCHEMA = " + Quote(schema))) { + string name = table["TABLE_NAME"].ToString(); + string filter = "VIEW_NAME = " + Quote(name); + Field[] fields = viewCols.Select(filter, "ORDINAL_POSITION") + .Select(c => new Field(c["COLUMN_NAME"].ToString(), typeFor(c["DATA_TYPE"].ToString()), + lengthFromColumn(c), c["IS_NULLABLE"].ToString() == "YES", false, + c["COLUMN_DEFAULT"] == System.DBNull.Value ? null : c["COLUMN_DEFAULT"].ToString())).ToArray(); + Table updateTable = null; + tables.TryGetValue(Regex.Replace(name, "^.*_", ""), out updateTable); + tables[name] = new View(name, fields, new Index[] { new Index("PRIMARY", fields[0]) }, + table["VIEW_DEFINITION"].ToString(), updateTable); + } + } + return tables; + } + + public Field FieldFor(Table table, string name) { + return table.Fields.FirstOrDefault(f => StringComparer.OrdinalIgnoreCase.Compare(f.Name, name) == 0); + } + + public void UpgradeTable(Table code, Table database, List insert, List update, List remove, + List insertFK, List dropFK, List insertIndex, List dropIndex) { + foreach (Index i in dropIndex) + DropIndex(database, i); + foreach (string s in dropFK.Where(f => code.Indexes.FirstOrDefault(i => i.Fields[0] == f) == null).Select(f => string.Format("DROP INDEX `fk_{0}_{1}_{2}_idx`", + code.Name, f.ForeignKey.Table.Name, f.Name))) { + executeLogSafe(string.Format("ALTER TABLE `{0}` {1}", code.Name, s)); + } + foreach (string s in insertFK.Where(f => code.Indexes.FirstOrDefault(i => i.Fields[0] == f) == null).Select(f => string.Format("DROP INDEX `fk_{0}_{1}_{2}_idx`", + code.Name, f.ForeignKey.Table.Name, f.Name))) { + executeLogSafe(string.Format("ALTER TABLE `{0}` {1}", code.Name, s)); + } + if (insert.Count != 0 || update.Count != 0 || remove.Count != 0 + || insertFK.Count != 0 || dropFK.Count != 0) { + List defs = new List(dropFK.Select(f => string.Format("DROP FOREIGN KEY `fk_{0}_{1}_{2}`", + code.Name, f.ForeignKey.Table.Name, f.Name))); + defs.AddRange(remove.Select(f => string.Format("DROP COLUMN `{0}`", f.Name))); + defs.AddRange(insert.Select(f => "ADD COLUMN " + fieldDef(f))); + defs.AddRange(update.Select(f => string.Format("CHANGE COLUMN `{0}` {1}", f.Name, fieldDef(f)))); + defs.AddRange(insertFK.Select(f => string.Format(@"ADD CONSTRAINT `fk_{0}_{1}_{2}` + FOREIGN KEY (`{2}`) + REFERENCES `{1}` (`{3}`) + ON DELETE NO ACTION + ON UPDATE NO ACTION", code.Name, f.ForeignKey.Table.Name, f.Name, f.ForeignKey.Table.PrimaryKey.Name))); + executeLog(string.Format("ALTER TABLE `{0}` {1}", code.Name, string.Join(",\r\n", defs.ToArray()))); + } + foreach (Index i in insertIndex) + CreateIndex(code, i); + foreach (string s in insertFK.Where(f => code.Indexes.FirstOrDefault(i => i.Fields[0] == f) == null).Select(f => string.Format("ADD INDEX `fk_{0}_{1}_{2}_idx` (`{2}` ASC)", + code.Name, f.ForeignKey.Table.Name, f.Name))) { + executeLog(string.Format("ALTER TABLE `{0}` {1}", code.Name, s)); + } + } + + public bool? ViewsMatch(View code, View database) { + return null; + } + + MySqlCommand command(string sql) { + try { + return new MySqlCommand(sql, _conn, _tran); + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + + int executeLog(string sql) { + WebServer.Log(sql); + using (MySqlCommand cmd = command(sql)) { + return cmd.ExecuteNonQuery(); + } + } + + int executeLogSafe(string sql) { + try { + return executeLog(sql); + } catch (Exception ex) { + WebServer.Log(ex.Message); + return -1; + } + } + + MySqlDataReader executeReader(MySqlCommand cmd, string sql) { + try { + return cmd.ExecuteReader(); + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + + string fieldDef(Field f) { + StringBuilder b = new StringBuilder(); + b.AppendFormat("`{0}` ", f.Name); + switch (f.Type.Name) { + case "Int32": + b.Append("INT"); + break; + case "Decimal": + b.AppendFormat("DECIMAL({0})", f.Length.ToString("0.0").Replace(System.Globalization.CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator, ",")); + break; + case "Double": + b.Append("DOUBLE"); + break; + case "Boolean": + b.Append("TINYINT(1)"); + break; + case "DateTime": + b.Append("DATETIME"); + break; + case "String": + if (f.Length == 0) + b.Append("TEXT"); + else + b.AppendFormat("VARCHAR({0})", f.Length); + break; + default: + throw new CheckException("Unknown type {0}", f.Type.Name); + } + b.AppendFormat(" {0}NULL", f.Nullable ? "" : "NOT "); + if (f.AutoIncrement) + b.Append(" AUTO_INCREMENT"); + else if(f.DefaultValue != null) + b.AppendFormat(" DEFAULT {0}", Quote(f.DefaultValue)); + return b.ToString(); + } + + decimal lengthFromColumn(DataRow c) { + string type = c["COLUMN_TYPE"].ToString(); + if (type == "double") return 10.4M; + Match m = Regex.Match(type, @"[\d,]+"); + return m.Success ? decimal.Parse(m.Value.Replace(",", System.Globalization.CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator)) : 0; + } + + JObject readRow(MySqlDataReader r, string sql) { + try { + if (!r.Read()) return null; + JObject row = new JObject(); + for (int i = 0; i < r.FieldCount; i++) { + row.Add(r.GetName(i), r[i].ToJToken()); + } + return row; + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + + static Type typeFor(string s) { + switch (s.ToLower()) { + case "int": + return typeof(int); + case "tinyint": + return typeof(bool); + case "decimal": + return typeof(decimal); + case "double": + case "float": + return typeof(double); + case "datetime": + case "date": + return typeof(DateTime); + case "varchar": + case "text": + default: + return typeof(string); + } + } + + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..652dd53 --- /dev/null +++ b/Program.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO; +using System.Configuration; +using System.Globalization; +using System.Threading; + +namespace AccountServer { + class Program { + static void Main(string[] args) { + try { + string configName = "AccountServer.config"; + string startPage = ""; + if (File.Exists(configName)) + AppSettings.Load(configName); + else + AppSettings.Default.Save(configName); + NameValueCollection flags = new NameValueCollection(); + foreach (string arg in args) { + string value = arg; + string name = Utils.NextToken(ref value, "="); + flags[name] = value; + if (value == "") { + switch (Path.GetExtension(arg).ToLower()) { + case ".config": + configName = arg; + AppSettings.Load(arg); + continue; + } + } + } + AppSettings.CommandLineFlags = flags; + bool windows = false; + switch (Environment.OSVersion.Platform) { + case PlatformID.Win32NT: + case PlatformID.Win32S: + case PlatformID.Win32Windows: + windows = true; + break; + } + if (flags["culture"] != null) { + CultureInfo c = new CultureInfo(flags["culture"]); + Thread.CurrentThread.CurrentCulture = c; + CultureInfo.DefaultThreadCurrentCulture = c; + CultureInfo.DefaultThreadCurrentUICulture = c; + } + if(flags["tz"] != null) + Utils._tz = TimeZoneInfo.FindSystemTimeZoneById(flags["tz"]); + if (flags["now"] != null) { + DateTime now = Utils.Now; + DateTime newDate = DateTime.Parse(flags["now"]); + if(newDate.Date == newDate) + newDate = newDate.Add(now - now.Date); + Utils._timeOffset = newDate - now; + } + new DailyLog("Logs." + AppSettings.Default.Port).WriteLine("Started:config=" + configName); + Utils.Check(!string.IsNullOrEmpty(AppSettings.Default.ConnectionString), "You must specify a ConnectionString in the " + configName + " file"); + // Force static database constructor to be called + Database.TableFor("Settings"); + if (flags["url"] != null) + startPage = flags["url"]; + if (windows && flags["nolaunch"] == null) + System.Diagnostics.Process.Start("http://localhost:" + AppSettings.Default.Port + "/" + startPage); + new WebServer().Start(); + } catch (Exception ex) { + WebServer.Log(ex.ToString()); + } + } + } + + /// + /// Class to maintain a dated log file + /// + + public class DailyLog : System.Diagnostics.TraceListener { + DateTime _lastDate = DateTime.MinValue; + string _logFolder; + StreamWriter _sw = null; + bool _autoclose; + + public DailyLog(string logFolder) { + _logFolder = logFolder; + _autoclose = false; + if (!Directory.Exists(_logFolder)) { + Directory.CreateDirectory(_logFolder); + } + System.Diagnostics.Trace.Listeners.Add(this); + } + + public override void Close() { + if (_sw != null) + _sw.Close(); + _sw = null; + } + + public override void Flush() { + Close(); + } + + /// + /// Purges log files with names dated earlier than the given date + /// + /// + /// Folder to look for the log files in + /// Ignores files that don't look like daily log files + + public void Purge(DateTime before, string folder) { + Regex mask = new Regex(@"^\d{4}-\d{2}-\d{2}\.log$", RegexOptions.IgnoreCase); + foreach (string file in Directory.GetFiles(folder, "*.log")) { + if (mask.IsMatch(Path.GetFileName(file))) { + DateTime date = DateTime.ParseExact(Path.GetFileNameWithoutExtension(file), "yyyy-MM-dd", new System.Globalization.CultureInfo("en-GB")); + if (date < before) File.Delete(file); + } + } + } + + public void Purge(DateTime before) { + Purge(before, _logFolder); + + } + + /// + /// Write exact text given to the file + /// + + public override void Write(string text) { + lock (this) { + open(); + try { + _sw.Write(text); + } finally { + if (_autoclose) Close(); + } + } + } + + /// + /// Write exact text given to the file + /// + /// Line to write or null to just flush the file when a new day starts + + public override void WriteLine(string line) { + lock (this) { + open(); + try { + _sw.WriteLine(Utils.Now.ToString("HH:mm:ss") + " " + line); + } finally { + if (_autoclose) Close(); + } + } + } + + string fileName() { + _lastDate = Utils.Today; + return fileName(_lastDate); + } + + string fileName(DateTime date) { + return Path.Combine(_logFolder, date.ToString("yyyy-MM-dd") + ".log"); + } + + void open() { + if (_sw == null || Utils.Today != _lastDate) { + if (_sw != null) { + _sw.Close(); + } + _sw = new StreamWriter(new FileStream(fileName(), FileMode.Append, FileAccess.Write, FileShare.ReadWrite), AppModule.Encoding); + _sw.AutoFlush = true; + } + } + + } + +} diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d24ba86 --- /dev/null +++ b/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("AccountServer")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("Trumphurst")] +[assembly: AssemblyProduct("AccountServer")] +[assembly: AssemblyCopyright("Copyright © 2015")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("ae3fde7c-dd41-48c1-8fbd-4f31ac4a02d8")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.1.0.0")] +[assembly: AssemblyFileVersion("1.1.0.0")] diff --git a/QifImporter.cs b/QifImporter.cs new file mode 100644 index 0000000..7db18de --- /dev/null +++ b/QifImporter.cs @@ -0,0 +1,800 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Import Quicken Import Format files + /// + public class QifImporter : FileProcessor { + AppModule _module; + TextReader _reader; + string _line; + bool _eof; + /// + /// First character of input line + /// + string _tag; + /// + /// Remainder of input line + /// + string _value; + /// + /// Account being processed + /// + int _account; + /// + /// Name of account being processed + /// + string _accountName; + /// + /// Transaction being built + /// + Transaction _transaction; + /// + /// Detailt line being built (last one on _transaction) + /// + Journal _detail; + /// + /// Helps identify transfers, which appear twice, once for each account, to stop them being posted twice + /// + List _accountsProcessed; + /// + /// All transactions found, ready to post + /// + List _transactions; + /// + /// True if only inputting a statement - nothing is posted + /// + bool _transactionsOnly; + + /// + /// Import a whole Qif file to the database as new accounts, transactions, etc. + /// + public void Import(TextReader r, AppModule module) { + lock (this) { + _transactionsOnly = false; + _reader = r; + _module = module; + Line = 0; + Character = 0; + _accountsProcessed = new List(); + _transactions = new List(); + if (!getLine()) return; + while (!_eof) { + switch (_line) { + case "!Type:Cash": + case "!Type:Bank": + importTransactions(DocType.Cheque, DocType.Deposit); + break; + case "!Type:CCard": + importTransactions(DocType.CreditCardCharge, DocType.CreditCardCredit); + break; + case "!Type:Invst": + importInvestments(); + break; + case "!Type:Oth A": + case "!Type:Oth L": + case "!Account": + importAccount(); + break; + case "!Type:Cat": + importCategories(); + break; + case "!Type:Security": + importSecurity(); + break; + case "!Type:Prices": + importPrices(); + break; + case "!Type:Class": + case "!Type:Memorized": + case "!Type:Invoice": + // We are not interested in these + // TODO: Process invoices + skip(); + break; + default: + if (_line.StartsWith("!Option:") || _line.StartsWith("!Clear:")) + getLine(); + else + throw new CheckException("Unexpected input:{0}", _line); + break; + } + } + postTransactions(); + } + } + + /// + /// Import a bank statement, and return it (no posting is made to the database) + /// + public JArray ImportTransactions(TextReader r, AppModule module) { + lock (this) { + JArray result = new JArray(); + _transactionsOnly = true; + _reader = r; + _module = module; + Line = 0; + Character = 0; + _accountsProcessed = new List(); + _transactions = new List(); + if (!getLine()) return result; + while (!_eof) { + switch (_line) { + case "!Type:Cash": + case "!Type:Bank": + importTransactions(DocType.Cheque, DocType.Deposit); + break; + case "!Type:CCard": + importTransactions(DocType.CreditCardCharge, DocType.CreditCardCredit); + break; + default: + skip(); + break; + } + } + // Now copy wanted data to the output + foreach (Transaction t in _transactions) { + JObject j = new JObject(); + j["Name"] = string.IsNullOrEmpty(t.Name) ? t.DocumentMemo : t.Name; + j["Amount"] = t.Amount; + j["Date"] = t.DocumentDate; + if (!string.IsNullOrEmpty(t.DocumentIdentifier)) j["Id"] = t.DocumentIdentifier; + j["Memo"] = t.DocumentMemo; + result.Add(j); + } + return result; + } + } + + /// + /// For progress bar + /// + public int Character { get; private set; } + + /// + /// Expected date format (if empty or null, DateTime.Parse is used) + /// + public string DateFormat; + + /// + /// For error reporting + /// + public int Line { get; private set; } + + /// + /// Input a wierd Quicken date + /// + /// + DateTime getDate() { + // 'nn is year 2000 + nn + _value = Regex.Replace(_value, @"'\d+$", delegate(Match m) { + int year = int.Parse(m.Value.Substring(1)); + return "/" + (year + 2000); + }); + return string.IsNullOrWhiteSpace(DateFormat) ? DateTime.Parse(_value) : DateTime.ParseExact(_value, DateFormat, System.Globalization.CultureInfo.InvariantCulture); + } + + /// + /// Get next line, split it into tag and value + /// + bool getLine() { + _line = _reader.ReadLine(); + Line++; + _eof = _line == null; + if (!_eof) { + _tag = _line.Substring(0, 1); + _value = _line.Length > 1 ? _line.Substring(1) : ""; + Character += _line.Length + 2; + } + return !_eof; + } + + /// + /// Import account info for a single bank/card/security account + /// + void importAccount() { + status("Importing Account"); + JObject o = new JObject(); + while (getLine()) { + switch (_tag) { + case "!": + return; + case "^": // End of record + if(!string.IsNullOrEmpty(o.AsString("AccountName"))) + _account = (int)_module.Database.ForeignKey("Account", o); + getLine(); // Get next line for caller to process + return; + case "N": + o["AccountName"] = _accountName = _value; + break; + case "D": + o["AccountDescription"] = _value; + break; + case "S": // Security stock ticker + break; + case "T": + switch (_value) { + case "CCard": + o["AccountTypeId"] = (int)AcctType.CreditCard; + break; + case "Bank": + o["AccountTypeId"] = (int)AcctType.Bank; + break; + case "Stock": + case "Invst": + o["AccountTypeId"] = (int)AcctType.Investment; + break; + default: + throw new CheckException("Unexpected account type:{0}", _line); + } + break; + default: + throw new CheckException("Unexpected input:{0}", _line); + } + } + } + + /// + /// Import series of categories (accounts, in our system) + /// + void importCategories() { + status("Importing Categories"); + JObject o = new JObject(); + while (getLine()) { + switch (_tag) { + case "!": + return; + case "^": // End of record + if(!string.IsNullOrEmpty(o.AsString("AccountName"))) + _account = (int)_module.Database.ForeignKey("Account", o); + o = new JObject(); + break; + case "N": + o["AccountName"] = _accountName = _value; + break; + case "D": + o["AccountDescription"] = _value; + break; + case "E": + o["AccountTypeId"] = (int)AcctType.Expense; + break; + case "I": + o["AccountTypeId"] = (int)AcctType.Income; + break; + case "B": + break; + default: + throw new CheckException("Unexpected input:{0}", _line); + } + } + } + + void importInvestments() { + decimal value; + status("Importing Investments"); + _accountsProcessed.Add(_account); + startInvestment(); + while (getLine()) { + switch (_tag) { + case "!": + return; + case "^": // End of record + addInvestment(); + startInvestment(); + break; + case "D": + _transaction.DocumentDate = getDate(); + break; + case "M": // Memo + if (_transaction.DocumentMemo == null) + _transaction.DocumentMemo = _value; + else if (_transaction.DocumentIdentifier == null) + _transaction.DocumentIdentifier = _value; + break; + case "N": + _transaction.DocumentIdentifier = _value; + break; + case "P": // Payee + _transaction.Name = _value; + break; + case "T": // Amount + value = decimal.Parse(_value); + _transaction.DocumentTypeId = (int)(value < 0 ? DocType.Buy : DocType.Sell); + _transaction.Amount = value; + break; + case "Y": // Security name + if (!string.IsNullOrEmpty(_value)) { + _transaction.SecurityName = _value; + _transaction.Stock.SecurityId = (int)_module.Database.ForeignKey("Security", "SecurityName", _value); + } + break; + case "Q": // Quantity (of shares) + _transaction.Stock.Quantity = double.Parse(_value); + break; + case "I": // Price + _transaction.Stock.Price = double.Parse(_value); + break; + case "O": // Commission cost + value = decimal.Parse(_value); + if (value != 0) { + Journal j = new Journal(); + _transaction.Journals.Add(j); + j.AccountId = (int)_module.Database.ForeignKey("Account", + "AccountName", _accountName + " fees", + "AccountTypeId", (int)AcctType.OtherExpense); + j.Memo = "Fees"; + j.Amount = j.Outstanding = decimal.Parse(_value); + } + break; + case "L": // Category/account + account(); + _detail.Amount = _detail.Outstanding = -_transaction.Amount; + break; + case "U": // ?? Same value as T + case "$": // Split amount + break; + case "C": // Cleared + case "E": // Split memo + case "S": // Split category/account + default: + throw new CheckException("Unexpected input:{0}", _line); + } + } + } + + /// + /// Stock prices + /// + void importPrices() { + status("Importing Prices"); + while (getLine()) { + switch (_tag) { + case "!": + return; + case "^": // End of record + getLine(); // Get next line for caller to process + return; + case "\"": + string[] fields = _line.Split(','); + string d = Utils.RemoveQuotes(fields[2]); + StockPrice p = new StockPrice(); + p.SecurityId = (int)_module.Database.ForeignKey("Security", "Ticker", Utils.RemoveQuotes(fields[0])); + p.Price = double.Parse(fields[1]); + p.Date = string.IsNullOrWhiteSpace(DateFormat) ? DateTime.Parse(d) : DateTime.ParseExact(d, DateFormat, System.Globalization.CultureInfo.InvariantCulture); + _module.Database.Update(p);; + break; + default: + throw new CheckException("Unexpected input:{0}", _line); + } + } + } + + void importSecurity() { + status("Importing Security"); + JObject o = new JObject(); + o["PriceDate"] = new DateTime(1900, 1, 1); + while (getLine()) { + switch (_tag) { + case "!": + return; + case "^": // End of record + if(!string.IsNullOrWhiteSpace(o.AsString("SecurityName")) && !string.IsNullOrWhiteSpace(o.AsString("Ticker"))) + _module.Database.ForeignKey("Security", o); + getLine(); // Get next line for caller to process + return; + case "N": + o["SecurityName"] = _value; + break; + case "S": // Security stock ticker + o["Ticker"] = _value; + break; + case "T": // Type? (Stock) + break; + default: + throw new CheckException("Unexpected input:{0}", _line); + } + } + } + + void importTransactions(DocType debit, DocType credit) { + decimal value; + string lAccount = null; + status("Importing Transactions"); + // For de-duplicating transfers + _accountsProcessed.Add(_account); + startTransaction(); + while (getLine()) { + switch (_tag) { + case "!": + return; + case "^": + addTransaction(); + startTransaction(); + lAccount = null; + break; + case "C": + _transaction.Cleared = _value; + break; + case "D": + _transaction.DocumentDate = getDate(); + break; + case "M": // Memo + if(_transaction.DocumentMemo == null) + _transaction.DocumentMemo = _value; + else if(_transaction.DocumentIdentifier == null) + _transaction.DocumentIdentifier = _value; + break; + case "N": + _transaction.DocumentIdentifier = _value; + break; + case "E": // Split memo + if (_detail.Memo != null) + _transaction.Journals.Add(_detail = new Journal()); + _detail.Memo = _value; + break; + case "L": // Category/account + lAccount = _value; + account(); + // Generate an initial journal line (in case there are no S split lines) + _detail.Amount = _detail.Outstanding = -_transaction.Amount; + break; + case "S": // Split category/account + if (_value == lAccount && _transaction.Journals.Count == 1) { + // Transaction has both L and S lines, and first S line is same as L + // We must ignore the initial journal line we generated from the L line (in case there were no S lines) + _detail.AccountId = 0; + _detail.Amount = _detail.Outstanding = 0; + } + lAccount = null; + account(); + break; + case "P": // Payee + _transaction.Name = _value; + break; + case "T": // Amount + value = decimal.Parse(_value); + _transaction.DocumentTypeId = (int)(value < 0 ? debit : credit); + _transaction.Amount = value; + break; + case "$": // Split amount + value = -decimal.Parse(_value); + if (value == 0) { + // Value is zero - we need to remove this line and go back to previous one + if (_transaction.Journals.Count > 1) { + _detail = _transaction.Journals[_transaction.Journals.Count - 2]; + _transaction.Journals.RemoveAt(_transaction.Journals.Count - 1); + } else { + // No previous line - re-initialise this line + _detail = new Journal(); + _transaction.Journals[0] = _detail; + } + break; + } + if (_detail.Amount != 0) + _transaction.Journals.Add(_detail = new Journal()); + _detail.Amount = value; + _detail.Outstanding = value; + break; + case "U": // ?? Same value as T + break; + case "Y": // Security name + case "Q": // Quantity (of shares) + case "I": // Price + case "O": // Commission cost + default: + throw new CheckException("Unexpected input:{0}", _line); + } + } + } + + /// + /// Have come across an account. + /// If necessary add a new journal, and set AccountId + /// + void account() { + if (string.IsNullOrEmpty(_value)) + return; + // Transfers are shown as "[accountname]" + string a = Regex.Replace(_value, @"^\[(.*)\]$", "$1"); + if (_detail.AccountId != 0) + _transaction.Journals.Add(_detail = new Journal()); + _detail.AccountId = _transactionsOnly ? + (int)_module.Database.LookupKey("Account", "AccountName", a, "AccountTypeId", (int)AcctType.Expense) : + (int)_module.Database.ForeignKey("Account", "AccountName", a, "AccountTypeId", (int)AcctType.Expense); + if (a != _value) + _transaction.DocumentTypeId = (int)DocType.Transfer; + } + + /// + /// Add an investment transaction to _transactions + /// + void addInvestment() { + _transaction.NameAddressId = string.IsNullOrWhiteSpace(_transaction.Name) ? 1 : (int)_module.Database.ForeignKey("NameAddress", + "Name", _transaction.Name, + "Type", "O"); + switch (_transaction.DocumentIdentifier) { + case "Buy": + addBuy(); + return; + case "BuyX": // Transfer money in from another account, and use it to buy + addTransfer(-1); + addBuy(); + return; + case "Sell": + addSell(); + return; + case "SellX": // Sell and transfer money out to another account + addSell(); + addTransfer(1); + return; + case "ReinvDiv": // Receive a dividend and use it to buy + addDividend(); + addBuy(); + return; + case "Div": + addDividend(); + return; + case "DivX": // Receive a dividend and transfer it to another account + addDividend(); + addTransfer(1); + return; + } + Utils.Check(_transaction.Stock.SecurityId == 0, "Unexpected stock transaction {0}", _transaction.DocumentIdentifier); + if (_accountsProcessed.Contains(_transaction.Journals[0].AccountId)) { + setClearedStatus(_transaction); + return; + } + _transaction.Stock = null; + Account acct = _module.Database.Get(_transaction.Journals[0].AccountId); + _transaction.DocumentTypeId = (int)(acct.AccountTypeId == (int)AcctType.Bank || acct.AccountTypeId == (int)AcctType.CreditCard || acct.AccountTypeId == (int)AcctType.Investment ? + DocType.Transfer : _transaction.Amount < 0 ? DocType.Cheque : DocType.Deposit); + _transactions.Add(_transaction); + } + + void addBuy() { + Utils.Check(_transaction.Stock.SecurityId != 0, "Stock transaction {0} without security", _transaction.DocumentIdentifier); + Transaction t = _transaction.Clone(); + t.DocumentTypeId = (int)DocType.Buy; + t.DocumentMemo = _transaction.DocumentMemo ?? _transaction.SecurityName; + decimal net = t.Amount; + if (t.Journals.Count > 1) + net -= t.Journals[1].Amount; + t.Amount = -t.Amount; + Journal d = t.Journals[0]; + d.AccountId = (int)_module.Database.ForeignKey("Account", + "AccountName", _accountName + ":" + t.SecurityName, + "AccountTypeId", (int)AcctType.Security); + d.Amount = d.Outstanding = net; + d.Memo = t.Stock.Quantity + " at " + t.Stock.Price; + _transactions.Add(t); + } + + void addSell() { + Utils.Check(_transaction.Stock.SecurityId != 0, "Stock transaction {0} without security", _transaction.DocumentIdentifier); + Transaction t = _transaction.Clone(); + t.DocumentTypeId = (int)DocType.Sell; + t.DocumentMemo = _transaction.DocumentMemo ?? _transaction.SecurityName; + decimal net = t.Amount; + if (t.Journals.Count > 1) + net += t.Journals[1].Amount; + Journal d = t.Journals[0]; + d.AccountId = (int)_module.Database.ForeignKey("Account", + "AccountName", _accountName + ":" + t.SecurityName, + "AccountTypeId", (int)AcctType.Security); + d.Amount = d.Outstanding = -net; + d.Memo = t.Stock.Quantity + " at " + t.Stock.Price; + t.Stock.Quantity = -t.Stock.Quantity; + _transactions.Add(t); + } + + void addDividend() { + Transaction t = new Transaction(); + t.AccountId = _account; + t.Amount = _transaction.Amount; + t.Cleared = _transaction.Cleared; + t.DocumentDate = _transaction.DocumentDate; + t.DocumentIdentifier = _transaction.DocumentIdentifier; + t.DocumentMemo = _transaction.DocumentMemo ?? _transaction.SecurityName; + t.DocumentTypeId = (int)DocType.Deposit; + t.Line = _transaction.Line; + t.NameAddressId = _transaction.NameAddressId; + t.Journals.Add(new Journal() { + AccountId = (int)_module.Database.ForeignKey("Account", + "AccountName", "Dividends", + "AccountTypeId", (int)AcctType.OtherIncome), + Amount = -_transaction.Amount, + Outstanding = -_transaction.Amount + }); + _transactions.Add(t); + } + + void addTransfer(int sign) { + Transaction t = new Transaction(); + Account a = _module.Database.Get(_detail.AccountId); + t.AccountId = _detail.AccountId; + t.Amount = sign * _transaction.Amount; + t.Cleared = _transaction.Cleared; + t.DocumentDate = _transaction.DocumentDate; + t.DocumentIdentifier = _transaction.DocumentIdentifier; + t.DocumentMemo = _transaction.DocumentMemo ?? _transaction.SecurityName; + t.DocumentTypeId = (int)(a.AccountTypeId == (int)AcctType.Bank || a.AccountTypeId == (int)AcctType.CreditCard || a.AccountTypeId == (int)AcctType.Investment ? DocType.Transfer : DocType.GeneralJournal); + t.Line = _transaction.Line; + t.NameAddressId = _transaction.NameAddressId; + t.Journals.Add(new Journal() { + AccountId = _account, + Amount = -t.Amount, + Outstanding = -t.Amount + }); + if (_accountsProcessed.Contains(_transaction.Journals[0].AccountId)) { + setClearedStatus(t); + return; + } + _transactions.Add(t); + } + + /// + /// Add current transaction to _transactions + /// + void addTransaction() { + if (!_transactionsOnly) { + _transaction.NameAddressId = string.IsNullOrWhiteSpace(_transaction.Name) ? 1 : (int)_module.Database.ForeignKey("NameAddress", + "Name", _transaction.Name, + "Type", "O"); + if (_accountsProcessed.Contains(_transaction.Journals[0].AccountId)) { + setClearedStatus(_transaction); + return; + } + } + _transactions.Add(_transaction); + } + + /// + /// Post all transactions in _transactions to the database + /// + void postTransactions() { + _module.Batch.Record = 0; + _module.Batch.Records = _transactions.Count; + status("Updating database"); + foreach (Transaction t in _transactions.OrderBy(t => t.DocumentDate)) { + _module.Batch.Record++; + decimal total = 0; + Line = t.Line; + Utils.Check(t.NameAddressId > 0, "No NameAddressId"); + _module.Batch.Record++; + _module.Database.Insert(t); + int docid = (int)t.idDocument; + int sign = t.DocumentTypeId == (int)DocType.Transfer || t.Amount < 0 ? 1 : -1; + Journal j = new Journal(); + j.DocumentId = docid; + j.JournalNum = 1; + j.NameAddressId = t.NameAddressId; + j.AccountId = t.AccountId; + j.Cleared = t.Cleared; + j.Memo = t.DocumentMemo; + j.Amount = j.Outstanding = t.Amount; + total += j.Amount; + _module.Database.Insert(j); + for (int i = 0; i < t.Journals.Count; i++) { + Journal d = t.Journals[i]; + d.DocumentId = docid; + d.JournalNum = i + 2; + d.NameAddressId = t.NameAddressId; + if(d.AccountId == 0) + d.AccountId = (int)_module.Database.ForeignKey("Account", "AccountName", "Uncategorised", "AccountTypeId", (int)AcctType.Expense); + total += d.Amount; + _module.Database.Insert(d); + Line l = new Line(); + l.idLine = d.idJournal; + l.LineAmount = sign * d.Amount; + _module.Database.Insert(l); + if (i == 0 && t.Stock != null) { + t.Stock.idStockTransaction = d.idJournal; + t.Stock.ParentAccountId = t.AccountId; + t.Stock.CostPer = -(double)t.Amount / t.Stock.Quantity; + _module.Database.Insert(t.Stock); + } + } + Utils.Check(total == 0, "Transaction total not zero {0}", t); + } + } + + /// + /// Set cleared status on other half of a transfer + /// + void setClearedStatus(Transaction t) { + if(t.Journals.Count != 1 || string.IsNullOrEmpty(t.Cleared)) + return; + int acct = t.Journals[0].AccountId; + if (acct > 0) { + Account account = _module.Database.Get(acct); + switch ((AcctType)account.AccountTypeId) { + case AcctType.Bank: + case AcctType.CreditCard: + case AcctType.Investment: + break; + default: + return; + } + Transaction other = _transactions.FirstOrDefault(o => o.AccountId == acct + && o.DocumentDate == t.DocumentDate + && o.Amount == -t.Amount + && o.NameAddressId == t.NameAddressId + && o.DocumentTypeId == (int)DocType.Transfer + && o.Journals.Count == 1 + && o.Journals[0].AccountId == t.AccountId); + if (other != null) + other.Journals[0].Cleared = t.Cleared; + } + } + + /// + /// Skip to next ! command line + /// + void skip() { + status("Skipping " + _line); + while (getLine() && _line[0] != '!') + ; + } + + /// + /// Set up transaction ready to process an investment + /// + void startInvestment() { + _transaction = new Transaction(); + _transaction.Line = Line; + _transaction.AccountId = _account; + _detail = new Journal(); + _transaction.Journals.Add(_detail); + _transaction.Stock = new StockTransaction(); + } + + /// + /// Set up transaction ready to process + /// + void startTransaction() { + _transaction = new Transaction(); + _transaction.Line = Line; + _transaction.AccountId = _account; + _detail = new Journal(); + _transaction.Journals.Add(_detail); + } + + /// + /// Set batch status (if it is a batch) + /// + /// + void status(string s) { + if (_module.Batch != null) + _module.Batch.Status = s; + } + + /// + /// Content of _transactions + /// + public class Transaction : Document { + public decimal Amount; + public string Name; + public int NameAddressId; + public int AccountId; + public int Line; + public string Cleared; + /// + /// The transaction lines, really - does not include the posting to AccountId + /// + public List Journals = new List(); + /// + /// For investments only + /// + public StockTransaction Stock; + public string SecurityName; + } + + } +} diff --git a/Query.cs b/Query.cs new file mode 100644 index 0000000..a064039 --- /dev/null +++ b/Query.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Hidden AppModule which allows you to query the database + /// + public class Query : AppModule { + /// + /// Column headings + /// + public JObject Headings; + + /// + /// Table name + /// + public string Table; + + /// + /// Call with the following parameters: + /// tables: comma-separated list of tables to join and display + /// fields: Comma-separated list of fields to display (default all) + /// f: Field to limit value of + /// v: Value to limit field to + /// + public override void Default() { + string tables = GetParameters["tables"]; + Utils.Check(!string.IsNullOrEmpty(tables), "No tables parameter supplied"); + Table = tables.Split(',')[0]; + JObject data = query(tables, "").FirstOrDefault(); + Headings = new JObject(); + foreach (JProperty p in data.Properties()) { + Headings.Add(p.Name, p.Value.Type == JTokenType.Null ? null : p.Value.Type.ToString().ToLower()); + } + } + + public IEnumerable DefaultListing(string tables) { + string f = GetParameters["f"]; + string v = GetParameters["v"]; + string where = ""; + if (!string.IsNullOrEmpty(f) && !string.IsNullOrEmpty(v)) { + Database.CheckValidFieldname(f); + where = "WHERE " + f + "=" + Database.Quote(v); + } + return query(tables, where); + } + + IEnumerable query(string tables, string where) { + string fields = GetParameters["fields"] ?? ""; + Utils.Check(Regex.IsMatch(fields, @"^[a-z+\*\.,]*$", RegexOptions.IgnoreCase), "Invalid fields parameter {0}", fields); + return Database.Query(fields, where, tables.Split(',')); + } + + } +} diff --git a/README.md b/README.md index e4f3f25..5271885 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,126 @@ -# AccountServer -Accounts package to replace Quick Books, Quicken or Microsoft Money. +#AccountServer Accounting Software + +##Installation + +###Windows + +* Extract all the files from the zip file into a folder of your choice. +* Open a command prompt as administrator (in your start menu, search for "CMD", right click it, and choose "Run as Administrator"). +* In the command prompt, register the port AccountsServer uses - type [*](#f1) **netsh http add urlacl url=http://+:8080/ user=Everyone** +* Close the command prompt. +* Open Windows Explorer and navigate to your chosen folder. +* If you wish to set up a shortcut in the start menu, right click on Accounts.exe and choose "Pin to Start". +* Start the program by double-clicking on it (or on your shortcut). +* The program will create an SQLite database, start up, and open your web browser at the Company page. + +* The **8080** here is the port on which the web server will listen. You can change this if you like, but you must also include a **Port=** line in the config file.[?](#a1) + +###Linux + +* Extract all the files from the zip file into a folder of your choice. +* Start the program by double-clicking on it. +* The program will create an SQLite database and start up. +* Open your web browser, and navigate to [*](#f1) **http://localhost:8080/** + +* The 8080 here is the port on which the web server is listening.[?](#a2) + +###Using a MySql Database + +If you wish to use a MySql database instead of SQLite, stop the program and: + +* Create an empty database in your MySql server (e.g. **accountsdb**). +* Create a new user (or use an existing user) who can connect from your machine (e.g. **accountsuser**, password **accountspassword**). +* Give that user full permissions on the database you just created. +* Edit the AccountServer.config file [*](#f3), and change the following items: + * "Database": "MySql", + * "ConnectionString":"server=**localhost**;user=**accountsuser**;database=**accountsdb**;port=3306;password=**accountspassword**", + +(Fill in the bold fields in the connection string above according to how you set up the database, replacing **localhost** with the machine running MySql if it is running elsewhere on your network.) + +* The config file is plain text in json format, so you can edit it with any text editor, e.g. Notepad (which comes with Windows).[?](#a3) + +##Importing Quick Books data + +Importing is on the Admin menu. + +Quick books does not show the full name of sub-accounts in its reports. It is therefore essential that you edit any subaccounts to give them unique names. E.g. rename the Taxes subaccount under Payroll to Payroll/Taxes. + +To transfer data from Quick Books, you only need 2 files. + +Use File, Utilites, Export to produce an IIF File - if you tick all the boxes, you can create a single file containing most of your data. The only other data you will need is a Custom Transaction Detail Report for the transactions. Customize the report, make sure all the fields listed below are selected, select All dates, run the report and then print it to a tab-delimited file. + +* Trans no +* Type +* Date +* Num +* Name +* Address 1 +* Address 2 +* Address 3 +* Address 4 +* Address 5 +* Memo +* Item +* Account +* Clr +* Open Balance +* Qty +* VAT Code +* VAT Rate +* VAT Amount +* Amount + +When importing, import your IIF Import File first, then go to Admin, Settings in this accounts package, and make sure everything is filled in, especially your financial year start - this is vital to ensure your VAT payments are matched correctly. + +Finally, import your Transaction Detail Report. + +Note that there is currently no way of importing payment history from Quick Books. + +##Importing Quicken data + +You can also import QIF files - but you should have transactions for all your accounts in a single file, because if you use a separate file for each account, transfers between accounts in different files will appear twice. + +##Importing data from other packages + +The system can import CSV or Tab delimited files from any system, provided they contain the correct fields. See the help link on the import screen for details of exactly which fields are required for each type of import. + +##Every day running + +The accounts package runs as a web server. While it is running, you can connect to it from any web server with access to your network (including phones and tablets which are on the same wireless network). The URL to connect is **http://localhost:8080/**, but with **localhost** replaced by the name or IP address of your computer. It is OK to leave the package running all day, and/or to add it to your startup group so it runs automatically when you log on. It could be run as a service (so it runs all the time your computer is switched on, even if you are not logged on), but this is not implemented yet (and would be different depending on whether you are running Linux or Windows). + +If you do leave the package running all the time, it would be a good idea to create a bookmark to it in your web browser for ease of access. + +Note that the Google Chrome browser gives the best user experience with this package. It can run in any browser, but most other browsers do support HTML5 as well (e.g. by offering drop-down calendars for dates). + +##Backup and Restore + +You should use the Backup option on the Admin menu regularly to backup your data. The backup is in standard JSON format. Note that Restore will overwrite all your data with the restored data, losing any changes made since you backed up. + +##Skins and changing user interface style + +You can now select "skins" to change the user interface style. Select the skin in Admin/Settings. + +###Adding your own skins + +You can add your own skins - to create a skin called **name**, just create 2 files, **name.css** and **name.js**,in the **html/skin** folder. You can enter any css you like in the css file to override the css in the regular **html/default.css** file. You can also add javascript in the js file (not recommended). + +#Using more than 1 database + +If you want to use more than 1 database (e.g. 1 for personal finances and one for company, or 1 for each person in your household), you can run multiple copies of AccountsServer, using different configuration files for each one. + +* Copy AccountServer.config in Windows Explorer: + * Right click on AccountsServer.config and choose "Copy" from the menu. + * Right click on a blank space in the Windows Explorer menu, and choose "Paste" from the menu. + * Right click on the new file (called something like "AccountServer - Copy.config"), and choose "Rename". + * Type in a new name (e.g. **Personal.config**). The name **must** end with **.config**. +* Edit the new config file: + * Change the ConnectionString setting to point to your new database. If the database is SQLite, all you need to change is the Data Source file name - e.g. **Personal.db"". + * Change the Port setting to a different number (e.g. **8081**). + * If running on Windows, register the new port: + * Open a command prompt as administrator (in your start menu, search for "CMD", right click it, and choose "Run as Administrator"). + * In the command prompt, register the new port - type **netsh http add urlacl url=http://+:8081/ user=Everyone** + * Close the command prompt. +* Now you can run another copy of AccountServer using the new config file - just create a shortcut to run "AccountServer Personal.config" (or whatever you called your config file). +* This copy will listen on the new port, and use the new database. +* You can connect to it by browsing to **http://localhost:8081/** + diff --git a/Reports.cs b/Reports.cs new file mode 100644 index 0000000..173d272 --- /dev/null +++ b/Reports.cs @@ -0,0 +1,2138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Reflection; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; + +namespace AccountServer { + public class Reports : AppModule { + public enum DateRange { + All = 1, + Today, + ThisWeek, + ThisMonth, + ThisQuarter, + ThisYear, + Yesterday, + LastWeek, + LastMonth, + LastQuarter, + LastYear, + Custom + } + /// + /// Fields which can be displayed in the report + /// + List _fields; + /// + /// Filters which can be applied + /// + List _filters; + /// + /// Sort orders which can be chosen + /// + JArray _sortOrders; + /// + /// Sort order to use + /// + string _sortOrder; + /// + /// Sort order split into fields + /// + string[] _sortFields; + bool _sortDescending; + /// + /// Whether to total + /// + bool _total; + /// + /// Whether to split lines + /// + bool _split; + /// + /// Whether change type required in Audit reports + /// + bool _changeTypeNotRequired; + /// + /// To use for filter selections + /// + Select _sel; + /// + /// All reports have a date filter + /// + DateFilter _dates; + + /// + /// Reports menu + /// + public override void Default() { + SessionData.Report = new JObject(); + Dictionary> groups = new Dictionary>(); + groups["Memorised Reports"] = new List(); + List reports = new List(); + reports.Add(new JObject().AddRange("ReportName", "Document Report", "ReportType", "Documents", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Transaction Report", "ReportType", "Transactions", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Journals Report", "ReportType", "Journals", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Profit and Loss", "ReportType", "ProfitAndLoss", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Balance Sheet", "ReportType", "BalanceSheet", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Trial Balance", "ReportType", "TrialBalance", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "VAT Detail Report", "ReportType", "VatDetail", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Ageing Report", "ReportType", "Ageing", "idReport", 0)); + groups["Standard Reports"] = reports; + reports = new List(); + reports.Add(new JObject().AddRange("ReportName", "Accounts List", "ReportType", "Accounts", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Names List", "ReportType", "Names", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Products List", "ReportType", "Products", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "VAT Codes List", "ReportType", "VatCodes", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Securities List", "ReportType", "Securities", "idReport", 0)); + groups["Lists"] = reports; + reports = new List(); + reports.Add(new JObject().AddRange("ReportName", "Audit Transactions Report", "ReportType", "AuditTransactions", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Audit Accounts Report", "ReportType", "AuditAccounts", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Audit Names Report", "ReportType", "AuditNames", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Audit Products Report", "ReportType", "AuditProducts", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Audit VAT Codes Report", "ReportType", "AuditVatCodes", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Audit Securities Report", "ReportType", "AuditSecurities", "idReport", 0)); + reports.Add(new JObject().AddRange("ReportName", "Reconciliation Report", "ReportType", "AuditReconciliation", "idReport", 0)); + groups["Audit Reports"] = reports; + foreach (JObject report in Database.Query("SELECT idReport, ReportGroup, ReportName, ReportType FROM Report ORDER BY ReportGroup, ReportName")) { + string group = report.AsString("ReportGroup"); + if (!groups.TryGetValue(group, out reports)) { + reports = new List(); + groups[group] = reports; + } + reports.Add(report); + } + Record = groups; + } + + public void Accounts(int id) { + Record = AccountsPost(getJson(id, "Accounts List")); + } + + public object AccountsPost(JObject json) { + initialiseReport(json); + accountSetup(); + setDefaultFields(json, "AccountName", "AccountDescription", "AcctType"); + makeSortable("AccountName", "AcctType", "AccountCode,AccountName=AccountCode"); + return finishReport(json, "Account", "AccountName", "LEFT JOIN AccountType ON AccountType.idAccountType = Account.AccountTypeId"); + } + + void accountSetup() { + addTable("Account"); + addTable("AccountType", "idAccountType", "AcctType"); + fieldFor("idAccountType").MakeEssential().Hide(); + _filters.Add(new StringFilter("AccountName", "Account.AccountName")); + _filters.Add(new StringFilter("AccountDescription", "Account.AccountDescription")); + _filters.Add(new RecordFilter("AccountType", "Account.AccountTypeId", _sel.AccountTypes(""))); + } + + public void AuditAccounts(int id) { + Record = AuditAccountsPost(getJson(id, "Accounts Audit Report")); + Method = "accounts"; + } + + public object AuditAccountsPost(JObject json) { + initialiseAuditReport(json); + accountSetup(); + return auditReportData(json, "Account", "AccountName", "AccountDescription", "AcctType"); + } + + /// + /// Audit history of an arbitrary record in an arbitrary table + /// + public void AuditHistory(string table, int id) { + Utils.Check(id > 0, "Invalid record id {0}", id); + JObject json = new JObject().AddRange( + "ReportName", "Audit trail", + "ReportType", "Audit" + table, + "idReport", null, + "recordId", id); + Record = AuditHistoryPost(json); + } + + public object AuditHistoryPost(JObject json) { + OriginalMethod = json.AsString("ReportType"); + Method = OriginalMethod.Substring(5).ToLower(); + MethodInfo method = this.GetType().GetMethod(OriginalMethod + "Post", BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); + Utils.Check(method != null, "Invalid table {0}", Method); + return method.Invoke(this, new object[] { json }); + } + + public void AuditNames(int id) { + Record = AuditNamesPost(getJson(id, "Names Audit Report")); + Method = "names"; + } + + public object AuditNamesPost(JObject json) { + initialiseAuditReport(json); + namesSetup(); + return auditReportData(json, "NameAddress", "Type", "Name", "Address", "PostCode", "Telephone", "Email", "Contact"); + } + + public void AuditProducts(int id) { + Record = AuditProductsPost(getJson(id, "Products Audit Report")); + Method = "products"; + } + + public object AuditProductsPost(JObject json) { + initialiseAuditReport(json); + addTable("Product"); + addTable("Account", "AccountName"); + addTable("VatCode"); + _filters.Add(new StringFilter("ProductName", "Product.ProductName")); + _filters.Add(new StringFilter("ProductDescription", "Product.ProductDescription")); + _filters.Add(new DecimalFilter("UnitPrice", "Product.UnitPrice")); + return auditReportData(json, "Product", "ProductName", "ProductDescription", "UnitPrice", "Code", "AccountName"); + } + + public void AuditSecurities(int id) { + Record = AuditSecuritiesPost(getJson(id, "Securities Audit Report")); + Method = "securities"; + } + + public object AuditSecuritiesPost(JObject json) { + initialiseAuditReport(json); + addTable("Security"); + _filters.Add(new StringFilter("SecurityName", "Security.SecurityName")); + _filters.Add(new StringFilter("Ticker", "Security.Ticker")); + return auditReportData(json, "Security", "SecurityName", "Ticker"); + } + + public void AuditReconciliation(int id) { + Record = AuditReconciliationPost(getJson(id, "Reconciliation Report")); + Method = "transactions"; + } + + public object AuditReconciliationPost(JObject json) { + // Not looking at changes - reconciliations are stored as created + _changeTypeNotRequired = true; + initialiseAuditReport(json); + addTable("!Account", "AccountName", "AccountDescription", "EndingBalance"); + _fields.Add(new ReportField("OpeningBalance", "decimal", "Opening Balance") { Table = "Account" }); + _fields.Add(new ReportField("ClearedBalance", "decimal", "Cleared Balance") { Table = "Account" }); + addTable("Extended_Document", "idDocument", "DocumentDate", "DocumentIdentifier", "DocumentName", "DocumentAddress", "DocumentAmount", "DocumentOutstanding", "DocType", "DocumentTypeId"); + fieldFor("idDocument")["heading"] = "Trans no"; + fieldFor("DocumentIdentifier")["heading"] = "Doc Id"; + fieldFor("DocumentTypeId").MakeEssential().Hide(); + addTable("Journal", "Amount", "Cleared"); + fieldFor("Cleared")["type"] = "checkbox"; + _filters.Add(new RecordFilter("Account", "idAccount", _sel.BankAccount(""))); + _split = true; + return auditReportData(json, "Reconciliation", "AccountName", "OpeningBalance", "EndingBalance", "ClearedBalance", "DocumentDate", "DocType", "DocumentIdentifier", "DocumentName", "Cleared", "Amount"); + } + + public void AuditTransactions(int id) { + Record = AuditTransactionsPost(getJson(id, "Transactions Audit Report")); + Method = "transactions"; + } + + public object AuditTransactionsPost(JObject json) { + initialiseAuditReport(json); + addTable("Extended_Document", "idDocument", "DocumentDate", "DocumentIdentifier", "DocumentName", "DocumentAddress", "DocumentAmount", "DocumentOutstanding", "DocType", "DocumentTypeId"); + fieldFor("idDocument")["heading"] = "Trans no"; + fieldFor("DocumentIdentifier")["heading"] = "Doc Id"; + fieldFor("DocumentTypeId").MakeEssential().Hide(); + addTable("Journal"); + addTable("Account", "AccountName"); + addTable("NameAddress", "Name"); + addTable("VatCode", "Code"); + addTable("Line"); + addTable("Product", "ProductName"); + _filters.Add(new DateFilter("DocumentDate", DateRange.All)); + _filters.Add(new StringFilter("Id", "DocumentIdentifier")); + _filters.Add(new DecimalFilter("DocumentAmount", "Extended_Document.DocumentAmount")); + _filters.Add(new DecimalFilter("DocumentOutstanding", "Extended_Document.DocumentOutstanding")); + _filters.Add(new RecordFilter("DocumentType", "DocumentTypeId", _sel.DocumentType(""))); + _filters.Add(new RecordFilter("Account", "Journal.AccountId", _sel.Account(""))); + _filters.Add(new RecordFilter("NameAddress", "Journal.NameAddressId", _sel.Name(""))); + _filters.Add(new RecordFilter("VatCode", "Line.VatCodeId", _sel.VatCode(""))); + _filters.Add(new RecordFilter("Product", "Line.ProductId", _sel.Product(""))); + _filters.Add(new DecimalFilter("JournalAmount", "Journal.Amount")); + _filters.Add(new StringFilter("Memo", "Journal.Memo")); + _split = true; + return auditReportData(json, "Document", "idDocument", "DocType", "DocumentDate", "Name", "DocumentIdentifier", "DocumentAmount", "DocumentOutstanding", "AccountName", "Debit", "Credit", "Qty", "Memo", "Code", "VatRate", "VatAmount"); + } + + public void AuditVatCodes(int id) { + Record = AuditVatCodesPost(getJson(id, "VAT Codes Audit Report")); + Method = "vatcodes"; + } + + public object AuditVatCodesPost(JObject json) { + initialiseAuditReport(json); + vatCodeSetup(); + return auditReportData(json, "Code", "VatDescription", "Rate"); + } + + /// + /// Ageing report splits outstdanding debt by date + /// + public void Ageing(int id) { + Record = AgeingPost(getJson(id, "Ageing Report")); + } + + public object AgeingPost(JObject json) { + initialiseReport(json); + JObject [] accountSelect = new JObject[] { + new JObject().AddRange("id", (int)Acct.SalesLedger, "value", "Sales Ledger"), + new JObject().AddRange("id", (int)Acct.PurchaseLedger, "value", "Purchase Ledger") + }; + ReportField acct = new ReportField("AccountId", "select", "Account"); + acct["selectOptions"] = new JArray(accountSelect); + acct.Essential = true; + _fields.Add(acct); + _fields.Add(new ReportField("NameAddressId", "int", "NameAddressId").Hide().MakeEssential()); + _fields.Add(new ReportField("Name", "string", "Name")); + // Fields for each ageing bucket + _fields.Add(new ReportField("SUM(CASE WHEN age BETWEEN 0 AND 29 THEN Outstanding ELSE 0 END) AS Current", "decimal", "Current")); + for(int i = 1; i < 90; i += 30) + _fields.Add(new ReportField("SUM(CASE WHEN age BETWEEN " + (i + 29) + " AND " + (i + 58) + " THEN Outstanding ELSE 0 END) AS b" + i, "decimal", i + "-" + (i + 29))); + _fields.Add(new ReportField("SUM(CASE WHEN age > 120 THEN Outstanding ELSE 0 END) AS old", "decimal", ">90")); + _fields.Add(new ReportField("SUM(Outstanding) AS Total", "decimal", "Total")); + RecordFilter account = new RecordFilter("Account", "Journal.AccountId", accountSelect); + account.Apply = false; + _filters.Add(account); + _sortOrder = ""; + _total = true; + setDefaultFields(json, "AccountId", "Name", "Current", "b1", "b31", "b61", "old", "Total"); + setFilters(json); // we need filters now! + string where = account.Active ? account.Where() : "AccountId IN (1, 2)"; + return finishReport(json, @"(SELECT AccountId, NameAddressId, Name, Outstanding, + DATEDIFF(" + Database.Quote(Utils.Today) + @", DocumentDate) AS age +FROM Journal +JOIN Document ON idDocument = DocumentId +JOIN NameAddress ON idNameAddress = NameAddressId +WHERE " + where + @" +AND Outstanding <> 0 +) AS DaysDue", "AccountId,Name", + "GROUP BY AccountId, Name"); + } + + public void BalanceSheet(int id) { + Record = BalanceSheetPost(getJson(id, "Balance Sheet")); + } + + public object BalanceSheetPost(JObject json) { + _total = false; + initialiseReport(json); + addTable("!AccountType"); + addTable("Account", "idAccount", "AccountCode", "AccountName", "AccountDescription"); + fieldFor("idAccount").Hide(); + fieldFor("AccountName")["sClass"] = "sa"; + fieldFor("Heading").MakeEssential(); + fieldFor("Negate").MakeEssential().Hide(); + fieldFor("BalanceSheet").MakeEssential().Hide(); + DateFilter date = new DateFilter("DocumentDate", DateRange.LastYear); + ReportField cp = new ReportField("CurrentPeriod", "decimal", "Current Period"); + _fields.Add(cp); + ReportField lp = new ReportField("PreviousPeriod", "decimal", "Previous Period"); + _fields.Add(lp); + _filters.Add(date); + setDefaultFields(json, "Heading", "AcctType", "AccountName", "CurrentPeriod", "PreviousPeriod"); + _sortOrder = "AcctType"; + setFilters(json); + // Balance sheet needs 2 period buckets for the 2 columns + DateTime[] cPeriod = date.CurrentPeriod(); + cp["heading"] = date.PeriodName(cPeriod); + DateTime[] lPeriod = date.PreviousPeriod(); + lp["heading"] = date.PeriodName(lPeriod); + string[] sort = new string[] { "AccountTypeId", "AccountCode", "AccountName" }; + string[] fields = _fields.Where(f => f.Include || f.Essential || _sortFields.Contains(f.Name)).Select(f => f.FullFieldName).Distinct().ToArray(); + // We want one record per account, with totals for each bucket, and an Old value + // which is sum of all transactions before first bucket (opening balance) + JObjectEnumerable report = Database.Query("SELECT " + string.Join(",", fields) + @", Old +FROM AccountType +LEFT JOIN Account ON Account.AccountTypeId = AccountType.idAccountType +JOIN (SELECT AccountId, +SUM(CASE WHEN DocumentDate < " + Database.Quote(cPeriod[1]) + " AND DocumentDate >= " + Database.Quote(cPeriod[0]) + @" THEN Amount ELSE 0 END) AS CurrentPeriod, +SUM(CASE WHEN DocumentDate < " + Database.Quote(lPeriod[1]) + " AND DocumentDate >= " + Database.Quote(lPeriod[0]) + @" THEN Amount ELSE 0 END) AS PreviousPeriod, +SUM(CASE WHEN DocumentDate < " + Database.Quote(lPeriod[0]) + @" THEN Amount ELSE 0 END) AS Old +FROM Journal +LEFT JOIN Document ON Document.idDocument = Journal.DocumentId +WHERE DocumentDate < " + Database.Quote(cPeriod[1]) + @" +GROUP BY AccountId +) AS Summary ON AccountId = idAccount +ORDER BY " + string.Join(",", sort.Select(s => s + (_sortDescending ? " DESC" : "")).ToArray()) + ); + _sortFields = new string[] { "Heading", "AcctType", "AccountCode", "AccountName" }; + // Report now needs further processing to: + // Calculate retained earnings account + // Add investment gains + // Consolidate P & L accounts and produce totals + return reportJson(json, fixBalanceSheet(addInvestmentGains(addRetainedEarnings(report), "Old", lPeriod[0], "PreviousPeriod", cPeriod[0], "CurrentPeriod", cPeriod[1])), "AccountType", "Account"); + } + + public void Documents(int id) { + Record = DocumentsPost(getJson(id, "Documents Report")); + Method = "transactions"; + } + + public object DocumentsPost(JObject json) { + initialiseReport(json); + addTable("Extended_Document"); + fieldFor("idDocument")["heading"] = "Trans no"; + fieldFor("DocumentIdentifier")["heading"] = "Doc Id"; + fieldFor("DocumentTypeId").MakeEssential().Hide(); + fieldFor("DocumentNameAddressId").Hide(); + fieldFor("DocumentAccountId").Hide(); + fieldFor("VatPaid")["type"] = "checkbox"; + addTable("NameAddress", "Type", "Telephone", "Email", "Contact"); + fieldFor("Type")["type"] = "select"; + fieldFor("Type")["selectOptions"] = new JArray(_sel.NameTypes()); + fieldFor("Email")["type"] = "email"; + _filters.Add(new DateFilter("DocumentDate", DateRange.ThisMonth)); + _filters.Add(new StringFilter("Id", "DocumentIdentifier")); + _filters.Add(new DecimalFilter("DocumentAmount", "Extended_Document.DocumentAmount")); + _filters.Add(new DecimalFilter("DocumentOutstanding", "Extended_Document.DocumentOutstanding")); + _filters.Add(new RecordFilter("DocumentType", "DocumentTypeId", _sel.DocumentType(""))); + _filters.Add(new RecordFilter("NameAddress", "DocumentNameAddressId", _sel.Name(""))); + _filters.Add(new StringFilter("DocumentMemo", "DocumentMemo")); + makeSortable("idDocument=Trans no", "DocumentDate", "DocumentIdentifier=Doc Id", "Type,DocumentName=Document Name", "DocumentAmount", "DocType"); + setDefaultFields(json, "idDocument", "DocType", "DocumentDate", "DocumentName", "DocumentIdentifier", "DocumentAmount", "DocumentOutstanding"); + return finishReport(json, "Extended_Document", "idDocument", "LEFT JOIN NameAddress ON NameAddress.idNameAddress = DocumentNameAddressId", + "Extended_Document"); + } + + public void Journals(int id) { + Record = JournalsPost(getJson(id, "Journals Report")); + } + + public object JournalsPost(JObject json) { + initialiseReport(json); + addTable("AccountType"); + fieldFor("idAccountType").Hide().Essential = false; + addTable("Account", "idAccount", "AccountCode", "AccountName", "AccountDescription"); + addTable("!Journal"); + addTable("!NameAddress"); + addTable("Document", "idDocument", "DocumentDate", "DocumentIdentifier", "DocumentTypeId"); + fieldFor("idDocument").MakeEssential()["heading"] = "Trans no"; + addTable("DocumentType", "DocType"); + fieldFor("DocumentIdentifier")["heading"] = "Doc Id"; + fieldFor("DocumentDate").FullFieldName = "rDocDate AS DocumentDate"; + fieldFor("DocumentTypeId").MakeEssential().Hide().FullFieldName = "rDocType AS DocumentTypeId"; + fieldFor("Amount").FullFieldName = "Result.Amount"; + fieldFor("idAccount").Hide().Essential = true; + DateFilter date = new DateFilter("DocumentDate", DateRange.ThisMonth); + RecordFilter account = new RecordFilter("Account", "Journal.AccountId", _sel.Account("")); + date.Apply = false; + account.Apply = false; + _filters.Add(date); + _filters.Add(account); + _filters.Add(new StringFilter("Id", "DocumentIdentifier")); + _filters.Add(new RecordFilter("DocumentType", "DocumentTypeId", _sel.DocumentType(""))); + _filters.Add(new RecordFilter("NameAddress", "Journal.NameAddressId", _sel.Name(""))); + _filters.Add(new DecimalFilter("JournalAmount", "Result.Amount")); + _filters.Add(new StringFilter("Memo", "Journal.Memo")); + _sortOrder = "idAccountType,AcctType,AccountName"; + makeSortable("idAccountType,AcctType,AccountCode,AccountName=Account Type", "AccountName", "AccountCode,AccountName=AccountCode", "Name", "DocumentDate", "DocumentIdentifier=Doc Id", "DocType"); + setDefaultFields(json, "AcctType", "AccountName", "Amount", "Memo", "Name", "DocType", "DocumentDate", "DocumentIdentifier"); + setFilters(json); // we need filters now! + string where = account.Active ? "\r\nAND " + account.Where() : ""; + // Need opening balance before start of period + // Journals in period + // Security gains/losses + List report = finishReport(@"( +SELECT * FROM +(SELECT Account.idAccount AS rAccount, Account.AccountTypeId as rAcctType, SUM(Journal.Amount) AS Amount, " + (int)DocType.OpeningBalance + " AS rDocType, 0 as rJournal, 0 as rDocument, 0 AS rJournalNum, " + + Database.Cast(Database.Quote(date.CurrentPeriod()[0]), "DATETIME") + @" AS rDocDate +FROM Account +LEFT JOIN AccountType ON AccountType.idAccountType = Account.AccountTypeId +LEFT JOIN Journal ON Journal.AccountId = Account.idAccount +LEFT JOIN Document ON Document.idDocument = Journal.DocumentId +WHERE DocumentDate < " + Database.Quote(date.CurrentPeriod()[0]) + @" +AND BalanceSheet = 1" + where + @" +GROUP BY AccountName) AS OpeningBalances +WHERE Amount <> 0 OR rAcctType IN (" + (int)AcctType.Investment + "," + (int)AcctType.Security + @") +UNION +SELECT Account.idAccount AS rAccount, Account.AccountTypeId as rAcctType, Journal.Amount, DocumentTypeId As rDocType, idJournal AS rJournal, idDocument as rDocument, + JournalNum as rJournal, DocumentDate AS rDocDate +FROM Account +LEFT JOIN AccountType ON AccountType.idAccountType = Account.AccountTypeId +LEFT JOIN Journal ON Journal.AccountId = Account.idAccount +LEFT JOIN Document ON Document.idDocument = Journal.DocumentId +WHERE " + date.Where() + where + @" +UNION +SELECT Account.idAccount AS rAccount, Account.AccountTypeId as rAcctType, 0 AS Amount, " + (int)DocType.Gain + " AS rDocType, 0 as rJournal, 0 as rDocument, 0 AS rJournalNum, " + + Database.Cast(Database.Quote(date.CurrentPeriod()[1].AddDays(-1)), "DATETIME") + @" AS rDocDate +FROM Account +WHERE AccountTypeId = " + (int)AcctType.Security + where.Replace("Journal.AccountId", "idAccount") + @" +) AS Result", "idAccountType,AccountName,DocumentDate,idDocument,JournalNum", @" +LEFT JOIN Account on Account.idAccount = rAccount +LEFT JOIN AccountType ON AccountType.idAccountType = Account.AccountTypeId +LEFT JOIN Journal ON Journal.idJournal = rJournal +LEFT JOIN NameAddress ON NameAddress.idNameAddress = Journal.NameAddressId +LEFT JOIN Document ON Document.idDocument = rDocument +LEFT JOIN DocumentType ON DocumentType.idDocumentType = rDocType +", json).ToList(); + return reportJson(json, addInvestmentGains(date.CurrentPeriod(), account, report), "Account", "AccountType"); + } + + public void Names(int id) { + Record = NamesPost(getJson(id, "Names List")); + } + + public object NamesPost(JObject json) { + initialiseReport(json); + namesSetup(); + makeSortable("Name", "Type"); + setDefaultFields(json, "Type", "Name", "Address", "PostCode", "Telephone", "Email", "Contact"); + return finishReport(json, "NameAddress", "Type,Name", ""); + } + + void namesSetup() { + addTable("NameAddress"); + fieldFor("Type").MakeEssential(); + fieldFor("Type")["type"] = "select"; + fieldFor("Type")["selectOptions"] = new JArray(_sel.NameTypes()); + fieldFor("Email")["type"] = "email"; + _filters.Add(new SelectFilter("Type", "NameAddress.Type", _sel.NameTypes())); + _filters.Add(new StringFilter("NameAddressDescription", "NameAddress.NameAddressDescription")); + _filters.Add(new StringFilter("PostCode", "NameAddress.PostCode")); + } + + public void Products(int id) { + Record = ProductsPost(getJson(id, "Products List")); + } + + public object ProductsPost(JObject json) { + initialiseReport(json); + addTable("Product"); + addTable("Account", "AccountCode", "AccountName", "AccountDescription"); + addTable("AccountType"); + addTable("VatCode"); + _filters.Add(new StringFilter("ProductName", "Product.ProductName")); + _filters.Add(new StringFilter("ProductDescription", "Product.ProductDescription")); + _filters.Add(new DecimalFilter("UnitPrice", "Product.UnitPrice")); + makeSortable("ProductName", "UnitPrice", "Code", "AccountName", "AccountCode,AccountName=AccountCode"); + setDefaultFields(json, "ProductName", "ProductDescription", "UnitPrice", "Code", "AccountName"); + return finishReport(json, "Product", "ProductName", @" +LEFT JOIN Account ON idAccount = AccountId +LEFT JOIN AccountType ON AccountType.idAccountType = Account.AccountTypeId +LEFT JOIN VatCode ON idVatCode = VatCodeId +"); + } + + public void ProfitAndLoss(int id) { + Record = ProfitAndLossPost(getJson(id, "Profit and Loss")); + } + + public object ProfitAndLossPost(JObject json) { + _total = false; + initialiseReport(json); + addTable("!AccountType"); + addTable("Account", "idAccount", "AccountCode", "AccountName", "AccountDescription"); + fieldFor("idAccount").Hide(); + fieldFor("AccountName")["sClass"] = "sa"; + fieldFor("Heading").MakeEssential().Hide(); + fieldFor("Negate").MakeEssential().Hide(); + fieldFor("BalanceSheet").MakeEssential().Hide(); + DateFilter date = new DateFilter("DocumentDate", DateRange.LastYear); + ReportField cp = new ReportField("SUM(Amount) AS CurrentPeriod", "decimal", "Current Period"); + _fields.Add(cp); + ReportField lp = new ReportField("SUM(Amount) AS PreviousPeriod", "decimal", "Previous Period"); + _fields.Add(lp); + _filters.Add(date); + setDefaultFields(json, "AcctType", "AccountName", "CurrentPeriod", "PreviousPeriod"); + _sortOrder = "AcctType"; + setFilters(json); + // P & L needs 2 period buckets for the 2 columns + DateTime[] cPeriod = date.CurrentPeriod(); + cp.FullFieldName = "SUM(CASE WHEN DocumentDate >= " + Database.Quote(cPeriod[0]) + " AND DocumentDate < " + Database.Quote(cPeriod[1]) + " THEN Amount ELSE 0 END) AS CurrentPeriod"; + cp["heading"] = date.PeriodName(cPeriod); + DateTime[] lPeriod = date.PreviousPeriod(); + lp.FullFieldName = "SUM(CASE WHEN DocumentDate >= " + Database.Quote(lPeriod[0]) + " AND DocumentDate < " + Database.Quote(lPeriod[1]) + " THEN Amount ELSE 0 END) AS PreviousPeriod"; + lp["heading"] = date.PeriodName(lPeriod); + string [] sort = new string[] { "AccountTypeId", "AccountCode", "AccountName" }; + string[] fields = _fields.Where(f => f.Include || f.Essential || _sortFields.Contains(f.Name)).Select(f => f.FullFieldName).Distinct().ToArray(); + JObjectEnumerable report = Database.Query("SELECT " + string.Join(",", fields) + + @" +FROM AccountType +LEFT JOIN Account ON Account.AccountTypeId = AccountType.idAccountType +JOIN Journal ON Journal.AccountId = Account.idAccount +LEFT JOIN Document ON Document.idDocument = Journal.DocumentId +" + + "\r\nWHERE BalanceSheet = 0" + + "\r\nAND ((DocumentDate >= " + Database.Quote(lPeriod[0]) + + "\r\nAND DocumentDate < " + Database.Quote(cPeriod[1]) + ")" + + "\r\nOR Account.AccountTypeId = " + (int)AcctType.Security + ")" + + "\r\nGROUP BY idAccount" + + "\r\nORDER BY " + string.Join(",", sort.Select(s => s + (_sortDescending ? " DESC" : "")).ToArray()) + ); + // Needs further processing to add investment gains + // total, etc. + return reportJson(json, fixProfitAndLoss(addInvestmentGains(report.ToList(), "Old", lPeriod[0], "PreviousPeriod", cPeriod[0], "CurrentPeriod", cPeriod[1])), "AccountType", "Account"); + } + + public void Securities(int id) { + Record = SecuritiesPost(getJson(id, "Securities List")); + } + + public object SecuritiesPost(JObject json) { + initialiseReport(json); + addTable("Security"); + addTable("StockPrice"); + _filters.Add(new StringFilter("SecurityName", "Security.SecurityName")); + _filters.Add(new StringFilter("Ticker", "Security.Ticker")); + makeSortable("SecurityName", "Ticker", "Date"); + setDefaultFields(json, "SecurityName", "Ticker", "Date", "Price"); + return finishReport(json, "Security", "SecurityName, Date", "JOIN StockPrice ON SecurityId = idSecurity", "Security"); + } + + public void Transactions(int id) { + Record = TransactionsPost(getJson(id, "Transactions Report")); + } + + public object TransactionsPost(JObject json) { + initialiseReport(json); + addTable("Extended_Document", "idDocument", "DocumentDate", "DocumentIdentifier", "DocumentName", "DocumentAddress", "DocumentAmount", "DocumentOutstanding", "DocType", "DocumentTypeId"); + fieldFor("idDocument")["heading"] = "Trans no"; + fieldFor("DocumentIdentifier")["heading"] = "Doc Id"; + fieldFor("DocumentTypeId").MakeEssential().Hide(); + addTable("Journal"); + addTable("Account", "AccountCode", "AccountName", "AccountDescription"); + addTable("AccountType"); + addTable("NameAddress"); + fieldFor("Type")["type"] = "select"; + fieldFor("Type")["selectOptions"] = new JArray(_sel.NameTypes()); + fieldFor("Email")["type"] = "email"; + addTable("Line"); + addTable("Product", "ProductName", "ProductDescription", "UnitPrice"); + fieldFor("UnitPrice")["heading"] = "List Price"; + addTable("VatCode"); + _filters.Add(new DateFilter("DocumentDate", DateRange.ThisMonth)); + _filters.Add(new StringFilter("Id", "DocumentIdentifier")); + _filters.Add(new DecimalFilter("DocumentAmount", "Extended_Document.DocumentAmount")); + _filters.Add(new DecimalFilter("DocumentOutstanding", "Extended_Document.DocumentOutstanding")); + _filters.Add(new RecordFilter("DocumentType", "DocumentTypeId", _sel.DocumentType(""))); + _filters.Add(new RecordFilter("Account", "Journal.AccountId", _sel.Account(""))); + _filters.Add(new RecordFilter("NameAddress", "Journal.NameAddressId", _sel.Name(""))); + _filters.Add(new DecimalFilter("JournalAmount", "Journal.Amount")); + _filters.Add(new StringFilter("Memo", "Journal.Memo")); + _filters.Add(new RecordFilter("VatCode", "Line.VatCodeId", _sel.VatCode(""))); + _filters.Add(new RecordFilter("Product", "Line.ProductId", _sel.Product(""))); + makeSortable("idDocument=Trans no", "DocumentDate", "DocumentIdentifier=Doc Id", "Type,DocumentName=Document Name", "DocumentAmount", "DocType"); + setDefaultFields(json, "idDocument", "DocType", "DocumentDate", "DocumentName", "DocumentIdentifier", "DocumentAmount", "DocumentOutstanding", "AccountName", "Debit", "Credit", "Qty", "Memo", "Code", "VatRate", "VatAmount"); + return finishReport(json, "Journal", "idDocument,JournalNum", @" +LEFT JOIN Line ON Line.idLine = Journal.idJournal +LEFT JOIN Extended_Document ON Extended_Document.idDocument = Journal.DocumentId +LEFT JOIN NameAddress ON NameAddress.idNameAddress = Journal.NameAddressId +LEFT JOIN VatCode ON VatCode.idVatCode = Line.VatCodeId +LEFT JOIN Account ON Account.idAccount = Journal.AccountId +LEFT JOIN AccountType ON AccountType.idAccountType = Account.AccountTypeId +LEFT JOIN Product ON Product.idProduct = Line.ProductId +", "Extended_Document", "DocumentType"); + } + + public void TrialBalance(int id) { + Record = TrialBalancePost(getJson(id, "Trial Balance")); + } + + public object TrialBalancePost(JObject json) { + _total = false; + initialiseReport(json); + addTable("!AccountType", "Heading", "AcctType"); + addTable("Account", "idAccount", "AccountCode", "AccountName", "AccountDescription"); + fieldFor("idAccount").Hide(); + addTable("Journal", "Amount"); + fieldFor("Amount").FullFieldName = "Amount"; + fieldFor("Credit").FullFieldName = "Amount"; + fieldFor("Debit").FullFieldName = "Amount"; + DateFilter date = new DateFilter("DocumentDate", DateRange.LastYear); + _filters.Add(date); + setDefaultFields(json, "AccountName", "Credit", "Debit"); + _sortOrder = "AcctType"; + setFilters(json); + DateTime[] cPeriod = date.CurrentPeriod(); + string[] sort = new string[] { "AccountTypeId", "AccountCode", "AccountName" }; + string[] fields = _fields.Where(f => f.Include || f.Essential || _sortFields.Contains(f.Name)).Select(f => f.FullFieldName).Distinct().ToArray(); + // Need Old (= opening balance) and final values for each account + JObjectEnumerable report = Database.Query("SELECT " + string.Join(",", fields) + @", BalanceSheet, Old +FROM AccountType +LEFT JOIN Account ON Account.AccountTypeId = AccountType.idAccountType +JOIN (SELECT AccountId, +SUM(CASE WHEN DocumentDate < " + Database.Quote(cPeriod[1]) + " AND DocumentDate >= " + Database.Quote(cPeriod[0]) + @" THEN Amount ELSE 0 END) AS Amount, +SUM(CASE WHEN DocumentDate < " + Database.Quote(cPeriod[0]) + @" THEN Amount ELSE 0 END) AS Old +FROM Journal +LEFT JOIN Document ON Document.idDocument = Journal.DocumentId +WHERE DocumentDate < " + Database.Quote(cPeriod[1]) + @" +GROUP BY AccountId +) AS Summary ON AccountId = idAccount +ORDER BY " + string.Join(",", sort.Select(s => s + (_sortDescending ? " DESC" : "")).ToArray()) + ); + _sortFields = new string[] { "Heading", "AcctType", "AccountCode", "AccountName" }; + // Need to add investment gains + // then process further to sort, add opening balances where required, and total + return reportJson(json, fixTrialBalance(addInvestmentGains(addRetainedEarnings(report), "Old", cPeriod[0], "Amount", cPeriod[1])), "AccountType", "Account"); + } + + public void VatCodes(int id) { + Record = VatCodesPost(getJson(id, "VAT Codes List")); + } + + public object VatCodesPost(JObject json) { + initialiseReport(json); + vatCodeSetup(); + makeSortable("Code", "VatDescription"); + setDefaultFields(json, "Code", "VatDescription", "Rate"); + return finishReport(json, "VatCode", "Code", ""); + } + + void vatCodeSetup() { + addTable("VatCode"); + _filters.Add(new StringFilter("Code", "VatCode.Code")); + _filters.Add(new StringFilter("VatCodeDescription", "VatCode.VatDescription")); + _filters.Add(new DecimalFilter("Rate", "VatCode.Rate")); + } + + public void VatDetail(int id) { + Record = VatDetailPost(getJson(id, "VAT Detail Report")); + } + + public object VatDetailPost(JObject json) { + initialiseReport(json); + _total = true; + addTable("Vat_Journal"); + fieldFor("idDocument")["heading"] = "Trans no"; + fieldFor("DocumentIdentifier")["heading"] = "Doc Id"; + fieldFor("DocumentTypeId").MakeEssential().Hide(); + fieldFor("DocumentAmount").FullFieldName = "DocumentAmount * Sign * VatType AS DocumentAmount"; + fieldFor("DocumentOutstanding").FullFieldName = "DocumentOutstanding * Sign * VatType AS DocumentOutstanding"; + fieldFor("VatType")["type"] = "select"; + fieldFor("VatType")["selectOptions"] = _sel.VatTypes().ToJToken(); + fieldFor("LineAmount").FullFieldName = "LineAmount * Sign * VatType AS LineAmount"; + fieldFor("VatAmount").FullFieldName = "VatAmount * Sign * VatType AS VatAmount"; + addTable("VatCode"); + _fields.Add(new ReportField("Payment.VatPaidDate", "date", "Vat Paid Date")); + positionField("VatType", 0); + positionField("Code", _fields.IndexOf(fieldFor("VatRate"))); + _filters.Add(new DateFilter("DocumentDate", DateRange.All)); + _filters.Add(new VatPaidFilter("VatPaid", "Vat_Journal.VatPaid", _sel.VatPayments())); + _filters.Add(new RecordFilter("DocumentType", "DocumentTypeId", _sel.DocumentType(""))); + _filters.Add(new SelectFilter("VatType", "VatType", _sel.VatTypes())); + makeSortable("idDocument=Trans no", "DocumentDate", "DocumentIdentifier=Doc Id", "Type,DocumentName=DocumentName", "DocumentAmount", "DocType", "Code"); + setDefaultFields(json, "VatType", "DocType", "DocumentDate", "DocumentIdentifier", "DocumentName", "Memo", "Code", "VatRate", "VatAmount", "LineAmount"); + return finishReport(json, "Vat_Journal", "VatType, DocumentDate", @"JOIN VatCode ON idVatCode = VatCodeId +LEFT JOIN (SELECT idDocument AS idVatPaid, DocumentDate AS VatPaidDate FROM Document) AS Payment ON Payment.idVatPaid = VatPaid", "Vat_Journal"); + } + + public AjaxReturn DeleteReport(int id) { + Report report = Database.Get(id); + Utils.Check(report.ReportGroup == "Memorised Reports", "Report not found"); + Database.Delete(report); + return new AjaxReturn() { message = "Report deleted" }; + } + + /// + /// Add/update current report, with settings, to memorised reports. + /// + /// + /// + public AjaxReturn SaveReport(JObject json) { + Report report = json.To(); + report.ReportGroup = "Memorised Reports"; + report.ReportSettings = json.ToString(); + Database.BeginTransaction(); + Database.Update(report); + Database.Commit(); + return new AjaxReturn() { message = "Report saved", id = report.idReport }; + } + + /// + /// Add a table to the report. + /// If a field list is supplied, exactly those fields are added. + /// Otherwise the id of the first table is added as an essential field, and all other non foreign key fields are added + /// If table is preceded by "!", its id is also added as an essential field + /// For Journal table, Credit & Debit are also added, as an alternative to Amount. + /// + void addTable(string table, params string[] fields) { + bool essential = _fields.FirstOrDefault(f => f.Essential) == null; + if (table.StartsWith("!")) { + table = table.Substring(1); + essential = false; + } + Table t = Database.TableFor(table); + foreach (Field f in fields.Length == 0 ? t.Fields.Where(f => (essential || f != t.PrimaryKey) && f.ForeignKey == null) : fields.Select(f => t.FieldFor(f))) { + ReportField r = new ReportField(t.Name, f); + if (essential) { + r.Essential = true; + if(Array.IndexOf(fields, f.Name) < 0) + r.Hidden = true; + essential = false; + } + _fields.Add(r); + if (table == "Journal" && f.Name == "Amount") { + ReportField rf = new ReportField(t.Name, f, "Debit"); + rf.Name = "Debit"; + rf["type"] = "debit"; + _fields.Add(rf); + rf = new ReportField(t.Name, f, "Credit"); + rf.Name = "Credit"; + rf["type"] = "credit"; + _fields.Add(rf); + } + } + } + + /// + /// Flatten an audit header-detail set into multiple records containing the header and 1 detail + /// + IEnumerable auditFlatten(IEnumerable data) { + foreach (JObject record in data) { + JObject r = JObject.Parse(record.AsString("Record")); + record.Remove("Record"); + JObject header = (JObject)r["header"]; + if (header == null) { + record.AddRange(r); + yield return record; + } else { + if(header["DocumentAmount"] == null) header["DocumentAmount"] = header["Amount"]; + if (header["DocumentName"] == null) header["DocumentName"] = header["Name"]; + if (header["DocumentOutstanding"] == null) header["DocumentOutstanding"] = header["Outstanding"]; + foreach (JObject detail in (JArray)r["detail"]) { + JObject result = new JObject(); + result.AddRange(record, header, detail); + yield return result; + } + } + } + } + + /// + /// Apply filters to audit records (which can't be done in SQL as record is a json blob) + /// + IEnumerable auditFilter(IEnumerable data) { + List filters = _filters.Where(f => f.Active).ToList(); + HashSet fields = new HashSet(); + foreach (ReportField f in _fields.Where(f => f.Include || f.Essential)) + fields.Add(f.FieldName); + foreach (JObject record in data) { + if (filters.FirstOrDefault(f => !f.Test(record)) == null) { + foreach (JProperty p in record.Properties().ToList()) + if (!fields.Contains(p.Name)) + record.Remove(p.Name); + yield return record; + } + } + } + + /// + /// Return the audit report data, flattened and filtered + /// + /// Report settings + /// Report type + /// Fields that appear by default, if user has not changed settings + object auditReportData(JObject json, string type, params string[] defaultFields) { + defaultFields = (_changeTypeNotRequired ? new string[] { "DateChanged" } : new string[] { "DateChanged", "ChangeType" }).Concat(defaultFields).ToArray(); + setDefaultFields(json, defaultFields); + setFilters(json); + string where = _dates.Active ? " AND " + _dates.Where() : ""; + if (json.AsInt("recordId") > 0) + where += " AND RecordId = " + json.AsInt("recordId"); + JObjectEnumerable report = Database.Query("SELECT ChangeType, DateChanged, Record FROM AuditTrail WHERE TableName = " + + Database.Quote(type) + + where + + " ORDER BY DateChanged, idAuditTrail"); + List tables = new List(); + tables.Add("AuditTrail"); + if (type == "Document") tables.Add("Extended_Document"); + else if (type == "Reconciliation") tables.Add("Account"); + _sortOrder = "idAuditTrail"; + return reportJson(json, auditFilter(auditFlatten(report)), tables.ToArray()); + } + + /// + /// Get report settings from database or session (or generate default settings) + /// + JObject getJson(int id, string defaultTitle) { + string reportType = OriginalMethod.ToLower(); + dynamic json = null; + if (PostParameters == null || PostParameters["json"] == null && SessionData.Report != null) { + json = SessionData.Report.reportType; + } + if (json == null || json.idReport != id || json.ReportType.ToString().ToLower() != reportType) { + json = readReport(id, OriginalMethod, defaultTitle); + } + return json; + } + + /// + /// Find a field in the field list by name + /// + ReportField fieldFor(string name) { + try { + return _fields.First(f => f.Name == name); + } catch { + throw new CheckException("Field {0} not in list", name); + } + } + + /// + /// Actually run the query for an ordinary report, and return the records + /// + IEnumerable finishReport(string tableName, string defaultSort, string joins, JObject json) { + setFilters(json); + List sort = new List(defaultSort.Split(',')); + if (_sortFields == null || _sortFields.Length == 0) { + _sortFields = new string[] { sort[0] }; + } else { + int p = 0; + foreach (string s in _sortFields) { + sort.Remove(s); + sort.Insert(p++, s); + } + } + string[] fields = _fields.Where(f => f.Include || f.Essential || _sortFields.Contains(f.Name)).Select(f => f.FullFieldName).Distinct().ToArray(); + return Database.Query("SELECT " + string.Join(",", fields) + + "\r\nFROM " + tableName + "\r\n" + joins + + getFilterWhere() + + "\r\nORDER BY " + string.Join(",", sort.Select(s => s + (_sortDescending ? " DESC" : "")).ToArray()) + ); + } + + /// + /// Run the query for an ordinary report, then package the result into a report display JObject + /// + object finishReport(JObject json, string tableName, string defaultSort, string joins, params string[] tables) { + return reportJson(json, finishReport(tableName, defaultSort, joins, json), tables); + } + + /// + /// Get the WHERE clause needed to action the filters + /// + string getFilterWhere(params string [] extraWheres) { + string[] where = _filters.Where(f => f.Active && f.Apply).Select(f => f.Where()).Concat(extraWheres).ToArray(); + if (where.Length == 0) + return ""; + return "\r\nWHERE " + string.Join("\r\nAND ", where); + } + + JObject getFilters() { + JObject result = new JObject(); + foreach (Filter f in _filters) { + result[f.AsString("data")] = f.Data(); + } + return result; + } + + void initialiseAuditReport(JObject json) { + initialiseReport(json); + if (_changeTypeNotRequired) { + addTable("!AuditTrail", "idAuditTrail", "DateChanged"); + } else { + addTable("!AuditTrail", "idAuditTrail", "DateChanged", "ChangeType"); + fieldFor("ChangeType")["type"] = "select"; + fieldFor("ChangeType")["selectOptions"] = new JArray(_sel.AuditTypes()); + } + fieldFor("DateChanged")["type"] = "dateTime"; + fieldFor("idAuditTrail").Hide(); + _dates = new DateFilter("DateChanged", DateRange.ThisMonth); + _filters.Add(_dates); + } + + void initialiseReport(JObject json) { + string reportType = OriginalMethod.ToLower().Replace("post", ""); + Utils.Check(json.AsString("ReportType").ToLower() == reportType, "Invalid report type"); + dynamic r = SessionData.Report; + if(r == null) + SessionData.Report = r = new JObject(); + r.reportType = json; + _fields = new List(); + _filters = new List(); + _sel = new Select(); + } + + void makeSortable(params string[] fieldNames) { + _sortOrders = new JArray(); + _sortOrders.Add(new JObject().AddRange("id", "", "value", "Default")); + foreach (string field in fieldNames) { + string value = field; + string id = Utils.NextToken(ref value, "="); + if (string.IsNullOrWhiteSpace(value)) value = id.UnCamel(); + _sortOrders.Add(new JObject().AddRange("id", id, "value", value)); + } + } + + JObject readReport(int id, string type, string defaultName) { + Report report = Database.Get(id); + JObject json; + if (report.idReport == null) { + report.ReportType = type; + report.ReportName = defaultName; + report.ReportSettings = "{}"; + } + if (PostParameters != null) { + JToken j = PostParameters["json"]; + if (j != null) + report.ReportSettings = j.ToString(); + } + json = JObject.Parse(report.ReportSettings); + json["ReportName"] = report.ReportName; + json["ReportType"] = report.ReportType; + json["idReport"] = report.idReport; + Utils.Check(report.ReportType == type, "Invalid report type"); + return json; + } + + List addRetainedEarnings(IEnumerable data) { + List result = data.ToList(); + if (result.FirstOrDefault(r => r.AsInt("idAccount") == (int)Acct.RetainedEarnings) == null) { + result.Add(new JObject().AddRange( + "idAccount", (int)Acct.RetainedEarnings, + "Heading", "Equities", + "BalanceSheet", 1, + "AccountTypeId", (int)AcctType.Equity, + "AcctType", "Equity", + "AccountName", "Retained Earnings", + "Negate", 1, + "CurrentPeriod", 0M, + "PreviousPeriod", 0M, + "Old", 0M + )); + } + return result; + } + + JObject newJournal(JObject jnl, decimal gain, bool loss, string securityName, DateTime [] period) { + jnl["idJournal"] = 0; + jnl["DocumentDate"] = period[1].AddDays(-1); + jnl["DocumentTypeId"] = jnl["idDocumentType"] = (int)DocType.Gain; + jnl["DocType"] = "Gain"; + jnl["Memo"] = (loss ? "Loss" : "Gain") + " on " + securityName + " for period " + period[0].ToShortDateString() + " to " + period[1].AddDays(-1).ToShortDateString(); + jnl["NameAddressId"] = jnl["idNameAddress"] = 1; + jnl["Name"] = ""; + jnl["Amount"] = gain; + return jnl; + } + + List addInvestmentGains(DateTime [] period, RecordFilter account, List data) { + Dictionary stockValues = new Dictionary(); + foreach (Investments.SecurityValue securityValue in Database.Query(Investments.SecurityValues(period[0].AddDays(-1)))) { + JObject jnl = data.FirstOrDefault(a => a.AsInt("idAccount") == securityValue.ParentAccountId && a.AsInt("DocumentTypeId") == (int)DocType.OpeningBalance); + if (jnl == null) + continue; + decimal gain = securityValue.Value; + stockValues[(int)securityValue.AccountId] = securityValue.Value; + jnl["Amount"] = jnl.AsDecimal("Amount") + gain; + } + foreach (Investments.SecurityValueWithName securityValue in Database.Query( + "SELECT * FROM (" + Investments.SecurityValues(period[1].AddDays(-1)) + @") AS SV +JOIN Security ON idSecurity = SecurityId")) { + decimal gain = 0; + int ind; + stockValues.TryGetValue((int)securityValue.AccountId, out gain); + gain = securityValue.Value - gain; + JObject jnl = data.LastOrDefault(a => a.AsInt("idAccount") == securityValue.ParentAccountId); + if (jnl != null && gain != 0) { + ind = data.IndexOf(jnl); + jnl = newJournal(new JObject(jnl), gain, gain < 0, securityValue.SecurityName, period); + data.Insert(ind + 1, jnl); + } + jnl = data.LastOrDefault(a => a.AsInt("idAccount") == securityValue.AccountId && a.AsInt("DocumentTypeId") == (int)DocType.Gain); + if (jnl != null) { + if (gain == 0) { + data.Remove(jnl); + } else { + jnl["AccountId"] = securityValue.AccountId; + if (account.Active && !account.Test(jnl)) + continue; + newJournal(jnl, -gain, gain < 0, securityValue.SecurityName, period); + } + } + } + return data; + } + + List addInvestmentGains(List data, params object[] p) { + string prior = null; + Dictionary stockValues = new Dictionary(); + for (int i = 0; i < p.Length; i += 2) { + string field = (string)p[i]; + DateTime date = (DateTime)p[i + 1]; + foreach (Investments.SecurityValue securityValue in Database.Query(Investments.SecurityValues(date.AddDays(-1)))) { + JObject securityAcct = data.FirstOrDefault(a => a.AsInt("idAccount") == securityValue.AccountId); + if (securityAcct == null) + continue; + decimal gain = securityValue.Value; + if (prior != null) + gain -= stockValues[(int)securityValue.AccountId]; + securityAcct[field] = securityAcct.AsDecimal(field) - gain; + stockValues[(int)securityValue.AccountId] = securityValue.Value; + JObject parentAcct = data.FirstOrDefault(a => a.AsInt("idAccount") == securityValue.ParentAccountId); + if (parentAcct != null) { + parentAcct[field] = parentAcct.AsDecimal(field) + gain; + Log(parentAcct.ToString()); + } + } + prior = field; + } + return data; + } + + // TODO: Should use common totalling code + IEnumerable fixBalanceSheet(IEnumerable data) { + _total = false; + JObject last = null; + string lastTotalBreak = null; + string lastHeading = null; + int sign = 1; + decimal retainedProfitCP = 0, retainedProfitPP = 0, retainedProfitOld = 0; + Dictionary totals = new Dictionary(); + JObject spacer = new JObject().AddRange("@class", "totalSpacer"); + foreach (ReportField f in _fields) { + if (!f.Include || f.Linked) continue; + string type = f.AsString("type"); + if (type != "decimal" && type != "double" && type != "credit" && type != "debit") continue; + totals[f.Name] = new decimal[3]; + } + Func totalRecord = delegate(int index) { + JObject t = new JObject().AddRange("@class", "total total" + index); + foreach (string f in totals.Keys.ToList()) { + t[f] = sign * totals[f][index]; + totals[f][index] = 0; + } + if (index == 0) { + t["Heading"] = lastHeading; + t[_sortOrder] = "Total " + lastTotalBreak; + } else { + t["Heading"] = "Total " + lastHeading; + } + return t; + }; + if(data.FirstOrDefault(r => r.AsInt("idAccount") == (int)Acct.RetainedEarnings) == null) { + data = data.Concat(Enumerable.Repeat(new JObject().AddRange( + "idAccount", (int)Acct.RetainedEarnings, + "Heading", "Equities", + "BalanceSheet", 1, + "AccountTypeId", (int)AcctType.Equity, + "AcctType", "Equity", + "AccountName", "Retained Earnings", + "Negate", 1, + "CurrentPeriod", 0M, + "PreviousPeriod", 0M, + "Old", 0M + ), 1)); + } + foreach (JObject r in data) { + JObject record = new JObject(r); + string totalBreak = record.AsString(_sortOrder); + string heading = record.AsString("Heading"); + if (record.AsInt("BalanceSheet") == 0) { + retainedProfitCP += record.AsDecimal("CurrentPeriod"); + retainedProfitPP += record.AsDecimal("PreviousPeriod"); + retainedProfitOld += record.AsDecimal("Old"); + continue; + } else { + if (r.AsInt("idAccount") == (int)Acct.RetainedEarnings) { + record["PreviousPeriod"] = record.AsDecimal("PreviousPeriod") + retainedProfitOld; + record["CurrentPeriod"] = record.AsDecimal("CurrentPeriod") + retainedProfitPP; + record.Remove("idAccount"); + } + record["PreviousPeriod"] = record.AsDecimal("PreviousPeriod") + record.AsDecimal("Old"); + record["CurrentPeriod"] = record.AsDecimal("CurrentPeriod") + record.AsDecimal("PreviousPeriod"); + record.Remove("Old"); + } + if (totalBreak != lastTotalBreak) { + if (last != null) { + if (lastTotalBreak != null) { + spacer["Heading"] = lastHeading; + yield return totalRecord(0); + yield return spacer; + if (lastHeading != heading) { + lastTotalBreak = lastHeading; + yield return totalRecord(1); + spacer.Remove("Heading"); + yield return spacer; + if (lastHeading.Contains("Assets") && !heading.Contains("Assets")) { + lastHeading = "Assets"; + sign = 1; + yield return totalRecord(2); + yield return spacer; + } + } + } + } + if (lastHeading != heading) + yield return new JObject().AddRange("@class", "title", "Heading", heading); + lastTotalBreak = totalBreak; + lastHeading = heading; + yield return new JObject().AddRange("@class", "title", "Heading", heading, "AcctType", totalBreak); + } + sign = record.AsBool("Negate") ? -1 : 1; + foreach (string f in totals.Keys.ToList()) { + decimal v = record.AsDecimal(f); + decimal[] tots = totals[f]; + for (int i = 0; i < tots.Length; i++) { + tots[i] += v; + } + record[f] = sign * v; + } + last = r; + yield return record; + if (r.AsInt("idAccount") == (int)Acct.RetainedEarnings) { + // Generate Net Income posting + record = new JObject(record); + record.Remove("idAccount"); + record["AccountName"] = "Net Income"; + record["AccountDescription"] = ""; + record["CurrentPeriod"] = -retainedProfitCP; + record["PreviousPeriod"] = -retainedProfitPP; + foreach (string f in totals.Keys.ToList()) { + decimal v = record.AsDecimal(f) * sign; + decimal[] tots = totals[f]; + for (int i = 0; i < tots.Length; i++) { + tots[i] += v; + } + } + yield return record; + } + } + if (last != null) { + yield return totalRecord(0); + yield return spacer; + lastHeading = "Liabilities & Equity"; + sign = -1; + yield return totalRecord(2); + yield return spacer; + } + } + + // TODO: Should be more like fixBalanceSheet, and use common totalling code + IEnumerable fixProfitAndLoss(IEnumerable data) { + _total = false; + JObject last = null; + string lastTotalBreak = null; + string lastHeading = null; + int sign = 1; + Dictionary totals = new Dictionary(); + JObject spacer = new JObject().AddRange("@class", "totalSpacer"); + foreach (ReportField f in _fields) { + if (!f.Include || f.Linked) continue; + string type = f.AsString("type"); + if (type != "decimal" && type != "double" && type != "credit" && type != "debit") continue; + totals[f.Name] = new decimal[3]; + } + Func totalRecord = delegate(int index) { + JObject t = new JObject().AddRange("@class", "total total" + index); + foreach (string f in totals.Keys.ToList()) { + t[f] = sign * totals[f][index]; + totals[f][index] = 0; + } + t["Heading"] = lastHeading; + t[_sortOrder] = index == 0 ? "Total " + lastTotalBreak : lastTotalBreak; + return t; + }; + foreach (JObject r in data) { + JObject record = new JObject(r); + string totalBreak = record.AsString(_sortOrder); + string heading = record.AsString("Heading"); + if (totalBreak != lastTotalBreak) { + if (last != null) { + if (lastTotalBreak != null) { + yield return totalRecord(0); + yield return spacer; + if (lastHeading != heading) { + lastTotalBreak = lastHeading; + sign = -1; + yield return totalRecord(1); + yield return spacer; + } + } + } + lastTotalBreak = totalBreak; + lastHeading = heading; + yield return new JObject().AddRange("@class", "title", "Heading", heading, "AcctType", totalBreak); + } + sign = record.AsBool("Negate") ? -1 : 1; + foreach (string f in totals.Keys.ToList()) { + decimal v = record.AsDecimal(f); + decimal[] tots = totals[f]; + for (int i = 0; i < tots.Length; i++) { + tots[i] += v; + } + record[f] = sign * v; + } + last = r; + yield return record; + } + if (last != null) { + yield return totalRecord(0); + yield return spacer; + lastTotalBreak = lastHeading; + sign = -1; + yield return totalRecord(2); + } + } + + IEnumerable fixTrialBalance(IEnumerable data) { + _total = false; + JObject last = null; + decimal retainedProfitOld = 0; + Dictionary totals = new Dictionary(); + foreach (ReportField f in _fields) { + if (!f.Include || f.Linked) continue; + string type = f.AsString("type"); + if (type != "decimal" && type != "double" && type != "credit" && type != "debit") continue; + totals[f.Name] = 0; + } + Func totalRecord = delegate() { + JObject t = new JObject().AddRange("@class", "total"); + foreach (string f in totals.Keys.ToList()) { + t[f] = totals[f]; + } + t["AccountName"] = "Total"; + return t; + }; + if (data.FirstOrDefault(r => r.AsInt("idAccount") == (int)Acct.RetainedEarnings) == null) { + data = data.Concat(Enumerable.Repeat(new JObject().AddRange( + "idAccount", (int)Acct.RetainedEarnings, + "Heading", "Equities", + "BalanceSheet", 1, + "AccountTypeId", (int)AcctType.Equity, + "AcctType", "Equity", + "AccountName", "Retained Earnings", + "Negate", 1, + "CurrentPeriod", 0M, + "PreviousPeriod", 0M, + "Old", 0M + ), 1)); + } + foreach (JObject r in data) { + JObject record = new JObject(r); + if (record.AsInt("BalanceSheet") == 0) { + retainedProfitOld += record.AsDecimal("Old"); + } else { + record["Amount"] = record.AsDecimal("Amount") + record.AsDecimal("Old"); + if (r.AsInt("idAccount") == (int)Acct.RetainedEarnings) { + record["Amount"] = record.AsDecimal("Amount") + retainedProfitOld; + record.Remove("idAccount"); + } + record.Remove("Old"); + } + decimal v = record.AsDecimal("Amount"); + if (v == 0) + continue; + foreach (string f in totals.Keys.ToList()) { + decimal v1 = v; + if (f == "Credit") { + v1 = v1 < 0 ? -v1 : 0; + } else if (f == "Debit") { + if (v1 < 0) v1 = 0; + } else { + record[f] = v; + } + totals[f] += v1; + } + last = r; + yield return record; + } + if (last != null) { + yield return totalRecord(); + } + } + + void positionField(string name, int position) { + ReportField f = fieldFor(name); + int p = _fields.IndexOf(f); + _fields.RemoveAt(p); + if (p < position) + position--; + _fields.Insert(position, f); + } + + IEnumerable removeRepeatsAndTotal(IEnumerable data, params string[] tables) { + JObject last = null; + JObject spacer = new JObject().AddRange("@class", "totalSpacer"); + HashSet flds = new HashSet(tables.SelectMany(t => Database.TableFor(t).Fields.Select(f => f.Name))); + HashSet fields = new HashSet(); + List essentialFields = _fields.Where(f => f.Essential).Select(f => f.Name).ToList(); + foreach (string f in _fields.Where(f => tables.Contains(f.Table)).Select(f => f.Name)) + fields.Add(f); + foreach (string f in flds.Where(f => !fields.Contains(f))) + fields.Add(f); + string[] sortFields = _sortFields == null ? new string[0] : _sortFields.Where(f => fieldFor(f).Include).ToArray(); + string[] lastTotalBreak = new string[sortFields.Length + 1]; + Dictionary totals = new Dictionary(); + string firstStringField = null; + if (_total) { + foreach (ReportField f in _fields) { + if (!f.Include) continue; + string type = f.AsString("type"); + if (firstStringField == null && type == "string" && !sortFields.Contains(f.Name)) + firstStringField = f.Name; + if (f.Name == "VatRate") continue; + if (type != "decimal" && type != "double" && type != "credit" && type != "debit") continue; + totals[f.Name] = new decimal [sortFields.Length + 1]; + } + } + Func totalRecord = delegate(int level) { + JObject t = new JObject().AddRange("@class", "total"); + foreach (string f in totals.Keys.ToList()) { + t[f] = totals[f][level]; + totals[f][level] = 0; + } + if (firstStringField != null) + t[firstStringField] = level == sortFields.Length ? "Grand Total" : "Total"; + if(level < sortFields.Length) + t[sortFields[level]] = lastTotalBreak[level]; + lastTotalBreak[level] = null; + return t; + }; + foreach (JObject r in data) { + JObject record = new JObject(r); + JObject id = null; + if (essentialFields.Count > 0) { + id = new JObject(); + foreach (string f in essentialFields) { + id[f] = record[f]; + if (!fields.Contains(f)) + record.Remove(f); + } + } + if (last != null) { + if (_total) { + for (int level = sortFields.Length; level-- > 0; ) { + if (record.AsString(sortFields[level]) != lastTotalBreak[level]) { + if (lastTotalBreak[level] != null) { + yield return totalRecord(level); + yield return spacer; + } + } + } + } + foreach (string f in fields) { + if (last.AsString(f) == record.AsString(f)) + record.Remove(f); + else + break; + } + if (record.IsAllNull()) + continue; + } + if(id != null) + record["recordId"] = id; + if (_total) { + for(int level = 0; level < sortFields.Length; level++) + lastTotalBreak[level] = r.AsString(sortFields[level]); + } + if (_total) { + foreach (string f in totals.Keys.ToList()) { + decimal v = record.AsDecimal(f); + if (f == "Credit") { + v = r.AsDecimal("Amount"); + v = v < 0 ? -v : 0; + } else if (f == "Debit") { + v = r.AsDecimal("Amount"); + if (v < 0) v = 0; + } + for(int level = 0; level <= sortFields.Length; level++) + totals[f][level] += v; + } + } + last = r; + yield return record; + } + if (_total && last != null) { + for (int level = sortFields.Length; level-- > 0; ) { + if (lastTotalBreak[level] != null) { + yield return totalRecord(level); + yield return spacer; + } + } + yield return totalRecord(sortFields.Length); + } + } + + public JObject reportJson(JObject json, IEnumerable report, params string [] tables) { + Title = Regex.Replace(Title, "-[^-]*$", "- " + json.AsString("ReportName")); + if (_sortFields != null && _sortFields.Length > 0 && tables.Length > 0) { + ReportField sortField = fieldFor(_sortFields[0]); + tables[0] = sortField.Table; + int p = 0; + foreach(string f in _sortFields) + positionField(f, p++); + if (_split) { + ReportField fld = _fields.FirstOrDefault(f => f.Include && !tables.Contains(f.Table)); + if(fld != null) + fld["newRow"] = true; + } + } + json["fields"] = _fields.Where(f => !f.Hidden).ToJToken(); + json["filters"] = getFilters().ToJToken(); + json["sorting"] = new JObject().AddRange( + "sort", _sortOrder, + "desc", _sortDescending, + "total", _total, + "split", _split); + return new JObject().AddRange( + "settings", json, + "filters", new JArray(_filters), + "sortOrders", _sortOrders, + "report", removeRepeatsAndTotal(report, tables) + ); + } + + void setDefaultFields(JObject settings, params string [] fields) { + if (settings == null || settings["fields"] == null) { + foreach (string field in fields) + fieldFor(field).Include = true; + } else { + foreach (JObject f in (JArray)settings["fields"]) { + string name = f.AsString("Name"); + ReportField fld = _fields.FirstOrDefault(x => x.Name == name); + if(fld != null) + fld.Include = f.AsInt("Include") != 0; + } + } + } + + void setFilters(JObject json) { + if (json != null && json["filters"] != null) { + JObject fdata = (JObject)json["filters"]; + foreach (Filter f in _filters) { + JToken data = fdata[f.AsString("data")]; + if (data == null) + continue; + f.Parse(data); + } + JObject sdata = (JObject)json["sorting"]; + if (sdata != null) { + string s = sdata.AsString("sort"); + if (!string.IsNullOrWhiteSpace(s)) + _sortOrder = s; + _sortDescending = sdata.AsBool("desc"); + _total = sdata.AsBool("total"); + _split = sdata.AsBool("split"); + } + } + if (!string.IsNullOrWhiteSpace(_sortOrder)) + _sortFields = _sortOrder.Split(','); + } + + public class ReportField : JObject { + + public ReportField(string table, Field f) + : this(table, f, f.Name.UnCamel()) { + } + + public ReportField(string table, Field f, string heading) { + Table = table; + this["data"] = f.Name; + FullFieldName = Table + "." + f.Name; + this["heading"] = heading; + Name = f.Name; + switch (f.Type.Name) { + case "Int32": + this["type"] = "int"; + break; + case "Decimal": + this["type"] = "decimal"; + break; + case "Double": + this["type"] = "double"; + break; + case "Boolean": + this["type"] = "checkbox"; + break; + case "DateTime": + this["type"] = "date"; + break; + case "String": + this["type"] = "string"; + break; + default: + throw new CheckException("Unexpected field type {0}", f.Type.Name); + } + } + + public ReportField(string fullName, string type, string heading) { + FullFieldName = fullName; + Name = FieldName; + this["data"] = Name; + this["heading"] = heading; + this["type"] = type; + } + + public bool Essential; + + public string FullFieldName; + + public string FieldName { + get { + string[] parts = FullFieldName.Split('.', ' '); + return parts[parts.Length - 1]; + } + } + + public bool Include { + get { return this.AsInt("Include") != 0; } + set { this["Include"] = value ? 1 : 0; } + } + + public bool Linked { + get { return this.AsInt("Linked") != 0; } + set { this["Linked"] = value ? 1 : 0; } + } + + public ReportField MakeEssential() { + Essential = true; + return this; + } + + public ReportField Hide() { + Hidden = true; + return this; + } + + public string Name { + get { return this.AsString("Name"); } + set { this["Name"] = value; } + } + + public bool Sortable { + get { return this.AsInt("Sortable") != 0; } + set { this["Sortable"] = value ? 1 : 0; } + } + + public string Table; + + public bool Hidden; + + public override string ToString() { + return FullFieldName + "/" + base.ToString(); + } + + } + + public abstract class Filter : JObject { + string _fieldName; + + public Filter(string name) { + Name = name; + FieldName = name; + this.AddRange( + "data", name, + "heading", name.UnCamel() + ); + } + + public bool Active { get; protected set; } + + public bool Apply = true; + + protected string FieldName { + get { return _fieldName; } + set { + _fieldName = value; + string[] parts = value.Split('.', ' '); + JObjectFieldName = parts[parts.Length - 1]; + } + } + + protected string JObjectFieldName; + + public abstract JToken Data(); + + public string Name { get; private set; } + + public abstract void Parse(JToken json); + + public abstract string Where(); + + public abstract bool Test(JObject data); + } + + public class BooleanFilter : Filter { + int _value; + + public BooleanFilter(string name, string fieldName) + : this(name, fieldName, null) { + } + + public BooleanFilter(string name, string fieldName, bool? value) + : base(name) { + FieldName = fieldName; + this.AddRange("type", "selectFilter", "selectOptions", new JObject[] { + new JObject().AddRange("id", -1, "value", "No filter"), + new JObject().AddRange("id", 0, "value", "No"), + new JObject().AddRange("id", 1, "value", "Yes") + }); + switch (value) { + case null: _value = -1; break; + case false: _value = 0; break; + case true: _value = 1; break; + } + Active = _value >= 0; + } + + public override JToken Data() { + return _value.ToJToken(); + } + + public override void Parse(JToken json) { + _value = json.To(); + Active = _value >= 0; + } + + public override string Where() { + return FieldName + " = " + _value; + } + + public override bool Test(JObject data) { + int value = data.AsInt(JObjectFieldName); + return _value == value; + } + } + + public class DateFilter : Filter { + DateRange _range; + DateTime _start; // inclusive + DateTime _end; // exclusive + + public DateFilter(string name, DateRange range) + : base(name) { + Utils.Check(range < DateRange.Custom, "Invalid default date range"); + _range = range; + Active = _range != DateRange.All; + setDates(); + this["type"] = "dateFilter"; + } + + public DateTime[] CurrentPeriod() { + return new DateTime[] { _start, _end }; + } + + public string PeriodName(DateTime[] period) { + switch (_range) { + case DateRange.All: + return "All Dates"; + case DateRange.Today: + case DateRange.Yesterday: + return "Day " + period[0].ToString("d"); + case DateRange.ThisWeek: + case DateRange.LastWeek: + return "Week Commencing " + period[0].ToString("d"); + case DateRange.ThisMonth: + case DateRange.LastMonth: + return period[0].ToString("y"); + case DateRange.ThisQuarter: + case DateRange.LastQuarter: + return "Quarter Ending " + period[1].AddDays(-1).ToString("d"); + case DateRange.ThisYear: + case DateRange.LastYear: + return "Year Ending " + period[1].AddDays(-1).ToString("d"); + default: + return period[0].ToString("d") + " - " + period[1].AddDays(-1).ToString("d"); + } + } + + public override JToken Data() { + return new JObject().AddRange( + "range", _range, + "start", _start, + "end", _end); + } + + public override void Parse(JToken json) { + _range = (DateRange)(json as JObject).AsInt("range"); + if (_range == DateRange.Custom) { + _start = json["start"].To(); + _end = json["end"].To(); + } else { + setDates(); + } + Active = _range != DateRange.All; + } + + public DateTime[] PreviousPeriod() { + DateTime [] result = new DateTime[2]; + result[1] = _start; + switch (_range) { + case DateRange.All: + result[0] = _start; + break; + case DateRange.Today: + case DateRange.Yesterday: + result[0] = _start.AddDays(-1); + break; + case DateRange.ThisWeek: + case DateRange.LastWeek: + result[0] = _start.AddDays(-7); + break; + case DateRange.ThisMonth: + case DateRange.LastMonth: + result[0] = _start.AddMonths(-1); + break; + case DateRange.ThisQuarter: + case DateRange.LastQuarter: + result[0] = _start.AddMonths(-3); + break; + case DateRange.ThisYear: + case DateRange.LastYear: + result[0] = AppModule.AppSettings.YearStart(_start.AddDays(-1)); + break; + default: + result[0] = _start.AddYears(-1); + break; + } + return result; + } + + void setDates() { + switch (_range) { + case DateRange.All: + _start = DateTime.Parse("1900-01-01"); + _end = DateTime.Parse("2100-01-01"); + break; + case DateRange.Today: + _start = Utils.Today; + _end = _start.AddDays(1); + break; + case DateRange.ThisWeek: + _start = Utils.Today; + _start = _start.AddDays(-(int)_start.DayOfWeek); + _end = _start.AddDays(7); + break; + case DateRange.ThisMonth: + _start = Utils.Today; + _start = _start.AddDays(1 - (int)_start.Day); + _end = _start.AddMonths(1); + break; + case DateRange.ThisQuarter: + _start = AppSettings.QuarterStart(Utils.Today); + _end = _start.AddMonths(3); + break; + case DateRange.ThisYear: + _start = AppSettings.YearStart(Utils.Today); + _end = AppSettings.YearEnd(_start).AddDays(1); + break; + case DateRange.Yesterday: + _end = Utils.Today; + _start = _start.AddDays(-1); + break; + case DateRange.LastWeek: + _end = Utils.Today; + _end = _end.AddDays(-(int)_end.DayOfWeek); + _start = _end.AddDays(-7); + break; + case DateRange.LastMonth: + _end = Utils.Today; + _end = _end.AddDays(1 - (int)_end.Day); + _start = _end.AddMonths(-1); + break; + case DateRange.LastQuarter: + _start = AppSettings.QuarterStart(Utils.Today.AddMonths(-3)); + _end = _start.AddMonths(3); + break; + case DateRange.LastYear: + _end = AppSettings.YearStart(Utils.Today); + _start = AppSettings.YearStart(_end.AddDays(-1)); + break; + } + } + + public override string Where() { + return FieldName + " >= " + Database.Quote(_start) + " AND " + Name + " < " + Database.Quote(_end); + } + + public override bool Test(JObject data) { + DateTime date = data.AsDate(JObjectFieldName); + return date >= _start && date < _end; + } + } + + public class RecordFilter : Filter { + protected List _ids; + + public RecordFilter(string name, string fieldName, IEnumerable options) + : base(name) { + FieldName = fieldName; + this.AddRange("type", "multiSelectFilter", "selectOptions", options); + _ids = new List(); + } + + public override JToken Data() { + return _ids.ToJToken(); + } + + public override void Parse(JToken json) { + if (json == null) + _ids = null; + else + _ids = json.To>(); + if (_ids == null) + _ids = new List(); + Active = _ids.Count != 0; + } + + public void SelectAll() { + _ids = new List(); + foreach (JObject options in (IEnumerable)this["selectOptions"]) { + _ids.Add(options.AsInt("id")); + } + } + + public override string Where() { + return FieldName + " " + Database.In(_ids); + } + + public override bool Test(JObject data) { + int id = data.AsInt(JObjectFieldName); + return _ids.Contains(id); + } + } + + public class SelectFilter : Filter { + List _ids; + + public SelectFilter(string name, string fieldName, IEnumerable options) + : base(name) { + FieldName = fieldName; + this.AddRange("type", "multiSelectFilter", "selectOptions", options); + _ids = new List(); + } + + public override JToken Data() { + return _ids.ToJToken(); + } + + public override void Parse(JToken json) { + if (json == null) + _ids = null; + else + _ids = json.To>(); + if (_ids == null) + _ids = new List(); + Active = _ids.Count != 0; + } + + public void SelectAll() { + _ids = new List(); + foreach (JObject options in (IEnumerable)this["selectOptions"]) { + _ids.Add(options.AsString("id")); + } + } + + public override string Where() { + return FieldName + " " + Database.In(_ids); + } + + public override bool Test(JObject data) { + string id = data.AsString(JObjectFieldName); + return _ids.Contains(id); + } + } + + public class DecimalFilter : Filter { + public enum Comparison { + None = 0, + Zero, + NonZero, + Less, + Greater, + Equal, + NotEqual + } + Comparison _comparison; + decimal _value; + + public DecimalFilter(string name, string fieldName) + : this(name, fieldName, Comparison.None, 0) { + } + + public DecimalFilter(string name, string fieldName, Comparison comparison) + : this(name, fieldName, comparison, 0) { + } + + public DecimalFilter(string name, string fieldName, Comparison comparison, decimal value) + : base(name) { + FieldName = fieldName; + this["type"] = "decimalFilter"; + _comparison = comparison; + _value = value; + Active = _comparison > Comparison.None && _comparison <= Comparison.NotEqual; + } + + public override JToken Data() { + return new JObject().AddRange("comparison", _comparison, "value", _value); + } + + public override void Parse(JToken json) { + if (json == null) + _comparison = Comparison.None; + else { + JObject data = json as JObject; + _comparison = (Comparison)data.AsInt("comparison"); + if (_comparison > Comparison.NotEqual) + _comparison = Comparison.None; + _value = data.AsDecimal("value"); + } + Active = _comparison > Comparison.None && _comparison <= Comparison.NotEqual; + } + + + public override string Where() { + switch (_comparison) { + case Comparison.Zero: + return FieldName + " = 0"; + case Comparison.NonZero: + return FieldName + " <> 0"; + case Comparison.Less: + return FieldName + " <= " + _value; + case Comparison.Greater: + return FieldName + " >= " + _value; + case Comparison.Equal: + return FieldName + " = " + _value; + case Comparison.NotEqual: + return FieldName + " <> " + _value; + default: + return ""; + } + } + + public override bool Test(JObject data) { + decimal value = data.AsDecimal(JObjectFieldName); + switch (_comparison) { + case Comparison.Zero: + return value == 0; + case Comparison.NonZero: + return value != 0; + case Comparison.Less: + return value <= _value; + case Comparison.Greater: + return value >= _value; + case Comparison.Equal: + return value == _value; + case Comparison.NotEqual: + return value != _value; + default: + return true; + } + } + } + + public class StringFilter : Filter { + public enum Comparison { + None = 0, + Empty, + NonEmpty, + Equal, + Contains, + StartsWith, + EndsWith + } + Comparison _comparison; + string _value; + + public StringFilter(string name, string fieldName) + : this(name, fieldName, Comparison.None, "") { + } + + public StringFilter(string name, string fieldName, Comparison comparison) + : this(name, fieldName, comparison, "") { + } + + public StringFilter(string name, string fieldName, Comparison comparison, string value) + : base(name) { + FieldName = fieldName; + this["type"] = "stringFilter"; + _comparison = comparison; + _value = value; + Active = _comparison > Comparison.None && _comparison <= Comparison.EndsWith; + } + + public override JToken Data() { + return new JObject().AddRange("comparison", _comparison, "value", _value); + } + + public override void Parse(JToken json) { + if (json == null) + _comparison = Comparison.None; + else { + JObject data = json as JObject; + _comparison = (Comparison)data.AsInt("comparison"); + _value = data.AsString("value"); + } + Active = _comparison > Comparison.None && _comparison <= Comparison.EndsWith; + } + + + public override string Where() { + switch (_comparison) { + case Comparison.Empty: + return "(" + FieldName + " IS NULL OR " + FieldName + " = '')"; + case Comparison.NonEmpty: + return FieldName + " <> ''"; + case Comparison.Equal: + return FieldName + " = " + Database.Quote(_value); + case Comparison.Contains: + return FieldName + " LIKE " + Database.Quote("%" + _value + "%"); + case Comparison.StartsWith: + return FieldName + " LIKE " + Database.Quote("%" + _value); + case Comparison.EndsWith: + return FieldName + " LIKE " + Database.Quote("%" + _value); + default: + return ""; + } + } + + public override bool Test(JObject data) { + string value = data.AsString(JObjectFieldName); + switch (_comparison) { + case Comparison.Empty: + return string.IsNullOrEmpty(value); + case Comparison.NonEmpty: + return !string.IsNullOrEmpty(value); + case Comparison.Equal: + return value == _value; + case Comparison.Contains: + return value.Contains(_value); + case Comparison.StartsWith: + return value.StartsWith(_value); + case Comparison.EndsWith: + return value.EndsWith(_value); + default: + return true; + } + } + } + + public class VatPaidFilter : RecordFilter { + bool _null; + + public VatPaidFilter(string name, string fieldName, IEnumerable options) + : base(name, fieldName, Enumerable.Repeat(new JObject().AddRange("id", "0", "value", "Not Paid"), 1).Concat(options)) { + this["date"] = true; + Active = true; + _null = true; + _ids.Add(0); + } + + public override void Parse(JToken json) { + base.Parse(json); + _null = _ids.IndexOf(0) >= 0; + } + + public override string Where() { + List clauses = new List(); + List list = _ids.Where(i => i != 0).ToList(); + if (list.Count > 0) + clauses.Add(FieldName + " " + Database.In(list)); + if (_null) + clauses.Add(FieldName + " IS NULL"); + return "(" + string.Join(" OR ", clauses.ToArray()) + ")"; + } + + public override bool Test(JObject data) { + if (_null && (data[JObjectFieldName] == null || data[JObjectFieldName].Type == JTokenType.Null)) + return true; + return base.Test(data); + } + } + + } +} diff --git a/SQLiteDatabase.cs b/SQLiteDatabase.cs new file mode 100644 index 0000000..236627c --- /dev/null +++ b/SQLiteDatabase.cs @@ -0,0 +1,494 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Reflection; +using System.Data; +using System.Data.Common; +using Mono.Data.Sqlite; +using System.IO; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + /// + /// Interface to SQLite + /// + public class SQLiteDatabase : DbInterface { + static object _lock = new object(); + SqliteConnection _conn; + SqliteTransaction _tran; + + static SQLiteDatabase() { + SqliteDateDiff.RegisterFunction(typeof(SqliteDateDiff)); + SqliteSum.RegisterFunction(typeof(SqliteSum)); + } + + public SQLiteDatabase(string connectionString) { + createDatabase(connectionString); + _conn = new SqliteConnection(); + _conn.ConnectionString = connectionString; + System.Diagnostics.Debug.WriteLine("Opening connection {0}", _conn.GetHashCode()); + _conn.Open(); + } + + public void BeginTransaction() { + lock (_lock) { + if (_tran == null) + _tran = _conn.BeginTransaction(); + } + } + + /// + /// Return SQL to cast a value to a type + /// + public string Cast(string value, string type) { + return value; + } + + public void CleanDatabase() { + foreach (string table in Database.TableNames) { + Table t = Database.TableFor(table); + if(t.PrimaryKey.AutoIncrement) + Execute(string.Format("UPDATE sqlite_sequence SET seq = (SELECT MAX({1}) FROM {0}) WHERE name='{0}'", + table, t.PrimaryKey.Name)); + } + Execute("VACUUM"); + } + + public void CreateTable(Table t) { + View v = t as View; + if (v != null) { + executeLog(string.Format("CREATE VIEW `{0}` AS {1}", v.Name, v.Sql)); + return; + } + createTable(t, t.Name); + createIndexes(t); + } + + public void CreateIndex(Table t, Index index) { + executeLog(string.Format("ALTER TABLE `{0}` ADD CONSTRAINT `{1}` UNIQUE ({2})", t.Name, index.Name, + string.Join(",", index.Fields.Select(f => "`" + f.Name + "` ASC").ToArray()))); + } + + public void Commit() { + if (_tran != null) { + lock (_lock) { + _tran.Commit(); + _tran.Dispose(); + _tran = null; + } + } + } + + public void Dispose() { + Rollback(); + if (_conn != null) { + System.Diagnostics.Debug.WriteLine("Closing connection {0}", _conn.GetHashCode()); + _conn.Dispose(); + _conn = null; + } + } + + public void DropTable(Table t) { + executeLogSafe("DROP TABLE IF EXISTS " + t.Name); + executeLogSafe("DROP VIEW IF EXISTS " + t.Name); + } + + public void DropIndex(Table t, Index index) { + executeLogSafe(string.Format("ALTER TABLE `{0}` DROP INDEX `{1}`", t.Name, index.Name)); + } + + int Execute(string sql) { + int lastInserttId; + return Execute(sql, out lastInserttId); + } + + public int Execute(string sql, out int lastInsertId) { + lock (_lock) { + using (SqliteCommand cmd = command(sql)) { + var ret = cmd.ExecuteNonQuery(); + cmd.CommandText = "select last_insert_rowid()"; + lastInsertId = (int)(Int64)cmd.ExecuteScalar(); + return ret; + } + } + } + + public bool FieldsMatch(Table t, Field code, Field database) { + if (code.TypeName != database.TypeName) return false; + if (t.IsView) return true; // Database does not always give correct values for view columns + if (code.AutoIncrement != database.AutoIncrement) return false; + if (code.Length != database.Length) return false; + if (code.Nullable != database.Nullable) return false; + if (code.DefaultValue != database.DefaultValue) return false; + return true; + } + + public IEnumerable Query(string query) { + lock (_lock) { + using (SqliteCommand cmd = command(query)) { + using (SqliteDataReader r = executeReader(cmd, query)) { + JObject row; + while ((row = readRow(r, query)) != null) { + yield return row; + } + } + } + } + } + + public JObject QueryOne(string query) { + return Query(query + " LIMIT 1").FirstOrDefault(); + } + + static public string Quote(object o) { + if (o == null || o == DBNull.Value) return "NULL"; + if (o is int || o is long || o is double) return o.ToString(); + if (o is decimal) return ((decimal)o).ToString("0.00"); + if (o is double) return (Math.Round((decimal)o, 4)).ToString(); + if (o is double) return ((decimal)o).ToString("0"); + if (o is bool) return (bool)o ? "1" : "0"; + if (o is DateTime) return "'" + ((DateTime)o).ToString("yyyy-MM-dd") + "'"; + return "'" + o.ToString().Replace("'", "''") + "'"; + } + + public void Rollback() { + if (_tran != null) { + lock (_lock) { + _tran.Rollback(); + _tran.Dispose(); + _tran = null; + } + } + } + + public Dictionary Tables() { + Dictionary tables = new Dictionary(); + createDatabase(AppSettings.Default.ConnectionString); + using (SqliteConnection conn = new SqliteConnection(AppSettings.Default.ConnectionString)) { + conn.Open(); + DataTable tabs = conn.GetSchema("Tables"); + DataTable cols = conn.GetSchema("Columns"); + DataTable fkeyCols = conn.GetSchema("ForeignKeys"); + DataTable indexes = conn.GetSchema("Indexes"); + DataTable indexCols = conn.GetSchema("IndexColumns"); + DataTable views = conn.GetSchema("Views"); + DataTable viewCols = conn.GetSchema("ViewColumns"); + foreach(DataRow table in tabs.Rows) { + string name = table["TABLE_NAME"].ToString(); + string filter = "TABLE_NAME = " + Quote(name); + Field[] fields = cols.Select(filter, "ORDINAL_POSITION") + .Select(c => new Field(c["COLUMN_NAME"].ToString(), typeFor(c["DATA_TYPE"].ToString()), + lengthFromColumn(c), c["IS_NULLABLE"].ToString() == "True", c["AUTOINCREMENT"].ToString() == "True", + defaultFromColumn(c))).ToArray(); + List tableIndexes = new List(); + foreach (DataRow ind in indexes.Select(filter + " AND PRIMARY_KEY = 'True'")) { + string indexName = ind["INDEX_NAME"].ToString(); + tableIndexes.Add(new Index("PRIMARY", + indexCols.Select(filter + " AND INDEX_NAME = " + Quote(indexName), "ORDINAL_POSITION") + .Select(r => fields.First(f => f.Name == r["COLUMN_NAME"].ToString())).ToArray())); + } + foreach (DataRow ind in indexes.Select(filter + " AND PRIMARY_KEY = 'False' AND UNIQUE = 'True'")) { + string indexName = ind["INDEX_NAME"].ToString(); + tableIndexes.Add(new Index(indexName, + indexCols.Select(filter + " AND INDEX_NAME = " + Quote(indexName), "ORDINAL_POSITION") + .Select(r => fields.First(f => f.Name == r["COLUMN_NAME"].ToString())).ToArray())); + } + tables[name] = new Table(name, fields, tableIndexes.ToArray()); + } + foreach (DataRow fk in fkeyCols.Rows) { + Table detail = tables[fk["TABLE_NAME"].ToString()]; + Table master = tables[fk["FKEY_TO_TABLE"].ToString()]; + Field masterField = master.FieldFor(fk["FKEY_TO_COLUMN"].ToString()); + detail.FieldFor(fk["FKEY_FROM_COLUMN"].ToString()).ForeignKey = new ForeignKey(master, masterField); + } + foreach (DataRow table in views.Select()) { + string name = table["TABLE_NAME"].ToString(); + string filter = "VIEW_NAME = " + Quote(name); + Field[] fields = viewCols.Select(filter, "ORDINAL_POSITION") + .Select(c => new Field(c["VIEW_COLUMN_NAME"].ToString(), typeFor(c["DATA_TYPE"].ToString()), + lengthFromColumn(c), c["IS_NULLABLE"].ToString() == "True", false, + defaultFromColumn(c))).ToArray(); + Table updateTable = null; + tables.TryGetValue(Regex.Replace(name, "^.*_", ""), out updateTable); + tables[name] = new View(name, fields, new Index[] { new Index("PRIMARY", fields[0]) }, + table["VIEW_DEFINITION"].ToString(), updateTable); + } + } + return tables; + } + + public void UpgradeTable(Table code, Table database, List insert, List update, List remove, + List insertFK, List dropFK, List insertIndex, List dropIndex) { + for (int i = dropIndex.Count; i-- > 0; ) { + Index ind = dropIndex[i]; + if ((ind.Fields.Length == 1 && ind.Fields[0].Name == code.PrimaryKey.Name) || ind.Name.StartsWith("sqlite_autoindex_")) + dropIndex.RemoveAt(i); + } + if (update.Count > 0 || remove.Count > 0 || insertFK.Count > 0 || dropFK.Count > 0 || insertIndex.Count > 0 || dropIndex.Count > 0) { + reCreateTable(code, database); + return; + } + if (insert.Count != 0) { + foreach(string def in insert.Select(f => "ADD COLUMN " + fieldDef(f))) { + executeLog(string.Format("ALTER TABLE `{0}` {1}", code.Name, def)); + } + } + } + + public bool? ViewsMatch(View code, View database) { + string c = Regex.Replace(code.Sql, @"[ \r\n\t]+", " ", RegexOptions.Singleline).Trim(); + string d = Regex.Replace(database.Sql, @"[ \r\n\t]+", " ", RegexOptions.Singleline).Trim(); + return c == d; + } + + SqliteCommand command(string sql) { + try { + return new SqliteCommand(sql, _conn, _tran); + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + + static void createDatabase(string connectionString) { + Match m = Regex.Match(connectionString, @"Data Source=([^;]+)", RegexOptions.IgnoreCase); + if (m.Success && !File.Exists(m.Groups[1].Value)) { + WebServer.Log("Creating SQLite database {0}", m.Groups[1].Value); + Directory.CreateDirectory(Path.GetDirectoryName(m.Groups[1].Value)); + SqliteConnection.CreateFile(m.Groups[1].Value); + } + } + + void createTable(Table t, string name) { + List defs = new List(t.Fields.Select(f => fieldDef(f))); + for (int i = 0; i < t.Indexes.Length; i++) { + Index index = t.Indexes[i]; + if (i == 0) { + if (index.Fields.Length != 1 || !index.Fields[0].AutoIncrement) + defs.Add(string.Format("CONSTRAINT `PRIMARY` PRIMARY KEY ({0})", string.Join(",", index.Fields + .Select(f => "`" + f.Name + "`").ToArray()))); + } else + defs.Add(string.Format("CONSTRAINT `{0}` UNIQUE ({1})", index.Name, + string.Join(",", index.Fields.Select(f => "`" + f.Name + "` ASC").ToArray()))); + } + defs.AddRange(t.Fields.Where(f => f.ForeignKey != null).Select(f => string.Format(@"CONSTRAINT `fk_{0}_{1}_{2}` + FOREIGN KEY (`{2}`) + REFERENCES `{1}` (`{3}`) + ON DELETE NO ACTION + ON UPDATE NO ACTION", t.Name, f.ForeignKey.Table.Name, f.Name, f.ForeignKey.Table.PrimaryKey.Name))); + executeLog(string.Format("CREATE TABLE `{0}` ({1})", name, string.Join(",\r\n", defs.ToArray()))); + } + + void createIndexes(Table t) { + foreach (string sql in t.Fields.Where(f => f.ForeignKey != null && t.Indexes.FirstOrDefault(i => i.Fields[0] == f) == null) + .Select(f => string.Format(@"CREATE INDEX `fk_{0}_{1}_{2}_idx` ON {0} (`{2}` ASC)", + t.Name, f.ForeignKey.Table.Name, f.Name))) + executeLog(sql); + } + + static string defaultFromColumn(DataRow def) { + if (def.IsNull("COLUMN_DEFAULT")) + return null; + string r = def["COLUMN_DEFAULT"].ToString(); + Match m = Regex.Match(r, @"^'(.*)'$"); + return m.Success ? m.Groups[1].Value : r; + } + + int executeLog(string sql) { + WebServer.Log(sql); + lock (_lock) { + using (SqliteCommand cmd = command(sql)) { + return cmd.ExecuteNonQuery(); + } + } + } + + int executeLogSafe(string sql) { + try { + return executeLog(sql); + } catch (Exception ex) { + WebServer.Log(ex.Message); + return -1; + } + } + + SqliteDataReader executeReader(SqliteCommand cmd, string sql) { + try { + return cmd.ExecuteReader(); + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + + string fieldDef(Field f) { + StringBuilder b = new StringBuilder(); + b.AppendFormat("`{0}` ", f.Name); + switch (f.Type.Name) { + case "Int32": + b.Append("INTEGER"); + break; + case "Decimal": + b.AppendFormat("DECIMAL({0})", f.Length.ToString("0.0").Replace(System.Globalization.CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator, ",")); + break; + case "Double": + b.Append("DOUBLE"); + break; + case "Boolean": + b.Append("BIT"); + break; + case "DateTime": + b.Append("DATETIME"); + break; + case "String": + if (f.Length == 0) + b.Append("TEXT"); + else + b.AppendFormat("VARCHAR({0})", f.Length); + b.Append(" COLLATE NOCASE"); + break; + default: + throw new CheckException("Unknown type {0}", f.Type.Name); + } + if (f.AutoIncrement) + b.Append(" PRIMARY KEY AUTOINCREMENT"); + else { + b.AppendFormat(" {0}NULL", f.Nullable ? "" : "NOT "); + if (f.DefaultValue != null) + b.AppendFormat(" DEFAULT {0}", Quote(f.DefaultValue)); + } + return b.ToString(); + } + + decimal lengthFromColumn(DataRow c) { + try { + switch (c["DATA_TYPE"].ToString().ToLower()) { + case "int": + case "integer": + return 11; + case "tinyint": + case "bit": + return 1; + case "decimal": + string s = c["NUMERIC_PRECISION"] + System.Globalization.CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator + c["NUMERIC_SCALE"]; + return s == "." ? 10.2M : decimal.Parse(s); + case "double": + case "float": + return 10.4M; + case "varchar": + return Convert.ToDecimal(c["CHARACTER_MAXIMUM_LENGTH"]); + default: + return 0; + } + } catch (Exception ex) { + WebServer.Log(ex.ToString()); + return 0; + } + } + + JObject readRow(SqliteDataReader r, string sql) { + try { + lock (_lock) { + if (!r.Read()) return null; + } + JObject row = new JObject(); + for (int i = 0; i < r.FieldCount; i++) { + row.Add(Regex.Replace(r.GetName(i), @"^.*\.", ""), r[i].ToJToken()); + } + return row; + } catch (Exception ex) { + throw new DatabaseException(ex, sql); + } + } + + void reCreateTable(Table code, Table database) { + string newTable = "_NEW_" + code.Name; + try { + executeLogSafe("PRAGMA foreign_keys=OFF"); + executeLog("BEGIN TRANSACTION"); + createTable(code, newTable); + executeLog(string.Format("INSERT INTO {0} ({2}) SELECT {2} FROM {1}", newTable, database.Name, + string.Join(", ", code.Fields.Select(f => f.Name) + .Where(f => database.Fields.FirstOrDefault(d => d.Name == f) != null).ToArray()))); + DropTable(database); + executeLog("ALTER TABLE " + newTable + " RENAME TO " + code.Name); + createIndexes(code); + executeLog("PRAGMA foreign_key_check"); + executeLog("COMMIT TRANSACTION"); + } catch (Exception ex) { + WebServer.Log("Exception: {0}", ex); + executeLogSafe("ROLLBACK TRANSACTION"); + throw; + } finally { + executeLogSafe("PRAGMA foreign_keys=ON"); + } + } + + static Type typeFor(string s) { + switch (s.ToLower()) { + case "int": + case "integer": + return typeof(int); + case "tinyint": + case "bit": + return typeof(bool); + case "decimal": + return typeof(decimal); + case "double": + case "float": + return typeof(double); + case "datetime": + case "date": + return typeof(DateTime); + case "varchar": + case "text": + default: + return typeof(string); + } + } + + } + + /// + /// DATEDIFF function (like MySql's) + /// + [SqliteFunctionAttribute(Name = "DATEDIFF", Arguments = 2, FuncType = FunctionType.Scalar)] + class SqliteDateDiff : SqliteFunction { + public override object Invoke(object[] args) { + if (args.Length < 2 || args[0] == null || args[0] == DBNull.Value || args[1] == null || args[1] == DBNull.Value) + return null; + try { + DateTime d1 = DateTime.Parse(args[0].ToString()); + DateTime d2 = DateTime.Parse(args[1].ToString()); + return (d1 - d2).TotalDays; + } catch (Exception ex) { + WebServer.Log("Exception: {0}", ex); + return null; + } + } + } + + /// + /// SUM function which rounds as it sums, so it works like MySql's + /// + [SqliteFunctionAttribute(Name = "SUM", Arguments = 1, FuncType = FunctionType.Aggregate)] + class SqliteSum : SqliteFunction { + public override void Step(object[] args, int stepNumber, ref object contextData) { + if (args.Length < 1 || args[0] == null || args[0] == DBNull.Value) + return; + try { + decimal d = Math.Round(Convert.ToDecimal(args[0]), 4); + if (contextData != null) d += (Decimal)contextData; + contextData = d; + } catch (Exception ex) { + WebServer.Log("Exception: {0}", ex); + } + } + + public override object Final(object contextData) { + return contextData; + } + } +} diff --git a/Select.cs b/Select.cs new file mode 100644 index 0000000..a25a091 --- /dev/null +++ b/Select.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + public class Select : AppModule { + + public JObjectEnumerable Account(string term) { + return Database.Query(@"idAccount AS id, AccountName AS value, AcctType AS category, Protected + HideAccount as hide", + like("WHERE HideAccount = 0 or HideAccount is null", "AccountName", term) + " ORDER BY idAccountType, AccountName", + "Account"); + } + + public JObjectEnumerable AllAccounts(string term) { + return Database.Query(@"idAccount AS id, AccountName AS value, AcctType AS category, HideAccount as hide", + like("", "AccountName", term) + " ORDER BY idAccountType, AccountName", + "Account"); + } + + public JObjectEnumerable AccountTypes(string term) { + return Database.Query(@"idAccountType AS id, AcctType AS value", + like("", "AcctType", term) + " ORDER BY idAccountType", + "AccountType"); + } + + public IEnumerable AuditTypes() { + for (AuditType t = AuditType.Insert; t <= AuditType.Delete; t++) { + yield return new JObject().AddRange("id", (int)t, "value", t.UnCamel()); + } + } + + public JObjectEnumerable BankAccount(string term) { + return Database.Query(@"idAccount AS id, AccountName AS value, AcctType AS category, HideAccount AS hide", + like("WHERE AccountTypeId in (" + (int)AcctType.Bank + "," + (int)AcctType.CreditCard + ")", "AccountName", term) + + " ORDER BY idAccountType, AccountName", + "Account"); + } + + public JObjectEnumerable BankOrStockAccount(string term) { + return Database.Query(@"idAccount AS id, AccountName AS value, AcctType AS category, HideAccount AS hide", + like("WHERE AccountTypeId in (" + (int)AcctType.Bank + "," + (int)AcctType.CreditCard + "," + (int)AcctType.Investment + ")", "AccountName", term) + + " ORDER BY idAccountType, AccountName", + "Account"); + } + + public JObjectEnumerable Customer(string term) { + return Name("C", term); + } + + public JObjectEnumerable DocumentType(string term) { + return Database.Query(@"idDocumentType AS id, DocType AS value", + like("", "DocType", term) + " ORDER BY idDocumentType", + "DocumentType"); + } + + public JObjectEnumerable ExpenseAccount(string term) { + return Database.Query(@"idAccount AS id, AccountName AS value, AcctType AS category, Protected + HideAccount as hide", + like("WHERE AccountTypeId " + Database.In(AcctType.Expense, AcctType.OtherExpense), "AccountName", term) + " ORDER BY idAccountType, AccountName", + "Account"); + } + + public JObjectEnumerable IncomeAccount(string term) { + return Database.Query(@"idAccount AS id, AccountName AS value, AcctType AS category, Protected + HideAccount as hide", + like("WHERE AccountTypeId = " + (int)AcctType.Income, "AccountName", term) + " ORDER BY idAccountType, AccountName", + "Account"); + } + + public JObjectEnumerable Name(string term) { + return Database.Query(@"idNameAddress AS id, Name AS value, CASE Type WHEN 'C' THEN 'Customers' WHEN 'S' THEN 'Suppliers' ELSE 'Others' END AS category, Hidden as hide", + like("", "Name", term) + " ORDER BY Type, Name", + "NameAddress"); + } + + public JObjectEnumerable Name(string nameType, string term) { + return Database.Query(@"idNameAddress AS id, Name AS value, Hidden as hide, Address, Telephone", + like("WHERE Type = " + Database.Quote(nameType), "Name", term) + " ORDER BY Name", + "NameAddress"); + } + + public IEnumerable NameTypes() { + return new JObject [] { + new JObject().AddRange("id", "C", "value", "Customer"), + new JObject().AddRange("id", "S", "value", "Supplier"), + new JObject().AddRange("id", "O", "value", "Other") + }; + } + + public JObjectEnumerable Other(string term) { + return Name("O", term); + } + + public JObjectEnumerable Product(string term) { + return Database.Query(@"idProduct AS id, ProductName AS value, ProductDescription, UnitPrice, VatCodeId, Code, VatDescription, Rate, AccountId, Unit", + like("", "ProductName", term) + " ORDER BY ProductName", + "Product"); + } + + public JObjectEnumerable ReportGroup(string term) { + return Database.Query(@"ReportGroup AS id, ReportGroup AS value", + like("", "ReportGroup", term) + " GROUP BY ReportGroup ORDER BY ReportGroup", + "Report"); + } + + public JObjectEnumerable Security(string term) { + return Database.Query(@"idSecurity AS id, SecurityName AS value", + like("", "SecurityName", term) + " ORDER BY SecurityName", + "Security"); + } + + public JObjectEnumerable Supplier(string term) { + return Name("S", term); + } + + public IEnumerable VatCode(string term) { + List result = Database.Query(@"idVatCode AS id, Code, VatDescription, Rate", + like("", "Code", term) + " ORDER BY Code", + "VatCode").ToList(); + foreach (JObject o in result) + o["value"] = o.AsString("Code") + " (" + o.AsDecimal("Rate") + "%)"; + result.Insert(0, new JObject().AddRange("id", null, + "value", "", + "Rate", 0)); + return result; + } + + public IEnumerable VatTypes() { + return new JObject[] { + new JObject().AddRange("id", -1, "value", "Sales"), + new JObject().AddRange("id", 1, "value", "Purchases") + }; + } + + public JObjectEnumerable VatPayments() { + return Database.Query(@"SELECT idDocument as id, DocumentDate as value +FROM Document +JOIN Journal ON DocumentId = idDocument +WHERE AccountId = 8 +AND JournalNum = 2 +AND DocumentTypeId IN (7, 8, 9, 10) +ORDER BY idDocument"); + } + + public string like(string sql, string name, string term) { + if (string.IsNullOrEmpty(term)) return sql; + term = name + " LIKE '" + term + "%'"; + return string.IsNullOrEmpty(sql) ? "WHERE " + term : sql + " AND " + term; + } + + } +} diff --git a/Settings.cs b/Settings.cs new file mode 100644 index 0000000..8d2713d --- /dev/null +++ b/Settings.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + public class AppSettings { + public string Database = "SQLite"; + public string ConnectionString = "Data Source=" + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData).Replace(@"\", "/") + + @"/AccountsServer/AccountServer.db"; + [JsonIgnore] + public string Filename; + public int Port = 8080; + public int SlowQuery = 100; + public string WebFolder = "html"; + public bool SessionLogging; + static public NameValueCollection CommandLineFlags; + + public static AppSettings Default = new AppSettings(); + + public void Save(string filename) { + using (StreamWriter w = new StreamWriter(filename)) { + w.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject(this, Newtonsoft.Json.Formatting.Indented)); + } + } + + static public void Load(string filename) { + WebServer.Log("Loading config from {0}", filename); + using (StreamReader s = new StreamReader(filename)) { + Default = Utils.JsonTo(s.ReadToEnd()); + Default.Filename = Path.GetFileNameWithoutExtension(filename); + } + } + } + + public class Admin : AppModule { + + public Admin() { + Menu = new MenuOption[] { + new MenuOption("Settings", "/admin/default.html"), + new MenuOption("Integrity Check", "/admin/integritycheck.html"), + new MenuOption("Import", "/admin/import.html"), + new MenuOption("Backup", "/admin/backup.html"), + new MenuOption("Restore", "/admin/restore.html") + }; + } + + public override void Default() { + JObject header = _settings.ToJObject(); + header.Add("YearStart", Settings.YearStart(Utils.Today)); + header.Add("YearEnd", Settings.YearEnd(Utils.Today)); + string skinFolder = Path.Combine(Config.WebFolder, "skin"); + Record = new JObject().AddRange("header", header, + "BankAccounts", new Select().BankAccount(""), + "Skins", Directory.EnumerateFiles(skinFolder, "*.css") + .Where(f => File.Exists(Path.ChangeExtension(f, ".js"))) + .Select(f => new { value = Path.GetFileNameWithoutExtension(f) }) + ); + } + + public AjaxReturn DefaultPost(Settings json) { + Database.BeginTransaction(); + Database.Update(json); + _settings = Database.QueryOne("SELECT * FROM Settings"); + Database.Commit(); + return new AjaxReturn() { message = "Settings saved", redirect = "/Admin" }; + } + + public AjaxReturn BatchStatus(int id) { + AjaxReturn result = new AjaxReturn(); + AppModule module = AppModule.GetBatchJob(id); + if (module == null) { + Log("Invalid batch id"); + result.error = "Invalid batch id"; + } else { + BatchJob batch = module.Batch; + if (batch == null) { + Log("Invalid batch id"); + result.error = "Invalid batch id"; + } else { + Log("Batch {0}:{1}%:{2}", batch.Id, batch.PercentComplete, batch.Status); + result.data = batch; + if (batch.Finished) { + result.error = batch.Error; + result.redirect = batch.Redirect; + Log("Batch finished - redirecting to {0}", batch.Redirect); + } + } + } + return result; + } + + public void Import() { + } + + public void ImportFile(UploadedFile file, string dateFormat) { + Method = "Import"; + Stream s = null; + try { + s = file.Stream(); + if (Path.GetExtension(file.Name).ToLower() == ".qif") { + QifImporter qif = new QifImporter(); + new ImportBatchJob(this, qif, delegate() { + try { + Batch.Records = file.Content.Length; + Batch.Status = "Importing file " + file.Name + " as QIF"; + Database.BeginTransaction(); + qif.DateFormat = dateFormat; + qif.Import(new StreamReader(s), this); + Database.Commit(); + } catch (Exception ex) { + throw new CheckException(ex, "Error at line {0}\r\n{1}", qif.Line, ex.Message); + } finally { + s.Dispose(); + } + Message = "File " + file.Name + " imported successfully as QIF"; + }); + } else { + CsvParser csv = new CsvParser(new StreamReader(s)); + Importer importer = Importer.ImporterFor(csv); + Utils.Check(importer != null, "No importer for file {0}", file.Name); + new ImportBatchJob(this, csv, delegate() { + try { + Batch.Records = file.Content.Length; + Batch.Status = "Importing file " + file.Name + " as " + importer.Name + " to "; + Database.BeginTransaction(); + importer.DateFormat = dateFormat; + importer.Import(csv, this); + Database.Commit(); + } catch (Exception ex) { + throw new CheckException(ex, "Error at line {0}\r\n{1}", csv.Line, ex.Message); + } finally { + s.Dispose(); + } + Message = "File " + file.Name + " imported successfully as " + importer.Name + " to " + importer.TableName; + }); + } + } catch (Exception ex) { + Log(ex.ToString()); + Message = ex.Message; + if (s != null) + s.Dispose(); + } + } + + class ImportBatchJob : BatchJob { + FileProcessor _file; + bool _recordReset; + + public ImportBatchJob(AppModule module, FileProcessor file, Action action) + : base(module, action) { + _file = file; + } + + public override int Record { + get { + return _recordReset ? base.Record : _file.Character; + } + set { + base.Record = value; + _recordReset = true; + } + } + } + + public Importer[] Importers { + get { return Importer.Importers; } + } + + public void ImportHelp() { + } + + public void IntegrityCheck() { + List errors = new List(); + foreach (JObject r in Database.Query(@"SELECT * FROM +(SELECT DocumentId, SUM(Amount) AS Amount +FROM Journal +GROUP BY DocumentId) AS r +LEFT JOIN Document ON idDocument = DocumentId +LEFT JOIN DocumentType ON idDocumentType = DocumentTypeId +WHERE Amount <> 0")) + errors.Add(string.Format("{0} {1} {2} {3:d} does not balance {4:0.00}", r.AsString("DocType"), + r.AsString("DocumentId"), r.AsString("DocumentIdentifier"), r.AsDate("DocumentDate"), r.AsDecimal("Amount"))); + foreach (JObject r in Database.Query(@"SELECT * FROM +(SELECT NameAddressId, SUM(Amount) AS Amount, Sum(Outstanding) As Outstanding +FROM Journal +WHERE AccountId IN (1, 2) +GROUP BY NameAddressId) AS r +LEFT JOIN NameAddress ON idNameAddress = NameAddressId +WHERE Amount <> Outstanding ")) + errors.Add(string.Format("Name {0} {1} {2} amount {3:0.00} does not equal outstanding {4:0.00} ", + r.AsString("NameAddressId"), r.AsString("Type"), r.AsString("Name"), r.AsDecimal("Amount"), r.AsDecimal("Outstanding"))); + foreach (JObject r in Database.Query(@"SELECT * FROM +(SELECT DocumentId, COUNT(idJournal) AS JCount, MAX(JournalNum) AS MaxJournal, COUNT(idLine) AS LCount +FROM Journal +LEFT JOIN Line ON idLine = idJournal +GROUP BY DocumentId) AS r +LEFT JOIN Document ON idDocument = DocumentId +LEFT JOIN DocumentType ON idDocumentType = DocumentTypeId +WHERE JCount < LCount + 1 +OR JCount > LCount + 2 +OR JCount != MaxJournal")) + errors.Add(string.Format("{0} {1} {2} {3:d} Journals={4} Lines={5} Max journal num={6}", r.AsString("DocType"), + r.AsString("DocumentId"), r.AsString("DocumentIdentifier"), r.AsDate("DocumentDate"), r.AsInt("JCount"), + r.AsInt("LCount"), r.AsInt("MaxJournal"))); + if (errors.Count == 0) + errors.Add("No errors"); + Record = errors; + } + + public void Backup() { + Database.Logging = LogLevel.None; + Database.BeginTransaction(); + DateTime now = Utils.Now; + JObject result = new JObject().AddRange("BackupDate", now.ToString("yyyy-MM-dd HH:mm:ss")); + foreach (string name in Database.TableNames) { + result.Add(name, Database.Query("SELECT * FROM " + name)); + } + Response.AddHeader("Content-disposition", "attachment; filename=AccountsBackup-" + now.ToString("yyyy-MM-dd-HH-mm-ss") + ".json"); + WriteResponse(Newtonsoft.Json.JsonConvert.SerializeObject(result, Newtonsoft.Json.Formatting.Indented), "application/json", System.Net.HttpStatusCode.OK); + } + + public void Restore() { + if (PostParameters != null && PostParameters["file"] != null) { + new BatchJob(this, delegate() { + Batch.Status = "Loading new data"; + UploadedFile data = PostParameters.As("file"); + Database.Logging = LogLevel.None; + Database.BeginTransaction(); + JObject d = data.Content.JsonTo(); + List
tables = Database.TableNames.Select(n => Database.TableFor(n)).ToList(); + Batch.Records = tables.Count * 4; + foreach (Table t in tables) { + if (d[t.Name] != null) { + Batch.Records += ((JArray)d[t.Name]).Count; + } + } + Batch.Status = "Deleting existing data"; + TableList orderedTables = new TableList(tables); + foreach(Table t in orderedTables) { + Database.Execute("DELETE FROM " + t.Name); + Batch.Record += 4; + } + Database.Logging = LogLevel.None; + foreach (Table t in orderedTables.Reverse
()) { + if (d[t.Name] != null) { + Batch.Status = "Restoring " + t.Name + " data"; + foreach (JObject record in (JArray)d[t.Name]) { + Database.Insert(t.Name, record); + Batch.Record++; + } + } + } + Batch.Status = "Checking database version"; + Database.Upgrade(); + Database.Commit(); + Batch.Status = "Compacting database"; + Database.Clean(); + _settings = Database.QueryOne("SELECT * FROM Settings"); + Batch.Status = Message = "Database restored successfully"; + }); + } + } + + } +} diff --git a/Supplier.cs b/Supplier.cs new file mode 100644 index 0000000..6131add --- /dev/null +++ b/Supplier.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + public class Supplier : CustomerSupplier { + + public Supplier() + : base("S", Acct.PurchaseLedger, DocType.Bill, DocType.Credit, DocType.BillPayment) { + } + + public object DetailListing(int id) { + return Database.Query("Document.*, DocType, -Amount AS Amount, -Outstanding AS Outstanding", + "WHERE AccountId = " + (int)LedgerAccount + " AND NameAddressId = " + id + " ORDER BY DocumentDate, idDocument", + "Document", "Journal"); + } + + public void Document(int id, DocType type) { + JObject record = document(id, type); + record["detail"] = Database.Query("idJournal, DocumentId, Line.VatCodeId, VatRate, JournalNum, Journal.AccountId, Memo, Qty, LineAmount, VatAmount", + "WHERE Journal.DocumentId = " + id + " AND idLine IS NOT NULL ORDER BY JournalNum", + "Document", "Journal", "Line"); + record.Add("Accounts", new Select().Account("")); + Record = record; + } + + protected override void calculatePaymentChanges(PaymentDocument json, decimal amount, out decimal changeInDocumentAmount, out decimal changeInOutstanding) { + PaymentHeader document = json.header; + PaymentHeader original = getDocument(document); + changeInDocumentAmount = -(document.DocumentAmount - original.DocumentAmount); + changeInOutstanding = 0; + Utils.Check(-changeInDocumentAmount == amount, "Change in document amount {0:0.00} does not agree with payments {1:0.00}", + -changeInDocumentAmount, amount); + } + + } + +} + diff --git a/Utils.cs b/Utils.cs new file mode 100644 index 0000000..bdc0355 --- /dev/null +++ b/Utils.cs @@ -0,0 +1,444 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.IO; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + public static class Utils { + /// + /// For converting between json string and JObject + /// + static JsonSerializer _converter; + + static Utils() { + _converter = new JsonSerializer(); + _converter.Converters.Add(new DecimalFormatJsonConverter()); + } + + /// + /// Add a NameValue collection to a JObject. Chainable. + /// + public static JObject AddRange(this JObject self, NameValueCollection c) { + foreach (string key in c.Keys) + self[key] = c[key]; + return self; + } + + /// + /// Add the properties of one JObject to another. Chainable. + /// + public static JObject AddRange(this JObject self, JObject c) { + foreach (JProperty p in c.Properties()) + self[p.Name] = p.Value; + return self; + } + + /// + /// Helper to add a list of stuff to a JObject. Chainable. + /// + /// Of the form: string, object - string = value + /// or JObject - adds properties of JObject + /// or NameValueCollection - adds collection members + public static JObject AddRange(this JObject self, params object[] content) { + string key = null; + foreach (object a in content) { + if (key == null) { + Type t = a.GetType(); + if (t == typeof(string)) { + key = (string)a; + } else if (t == typeof(JObject)) { + AddRange(self, (JObject)a); + } else if (typeof(NameValueCollection).IsAssignableFrom(t)) { + AddRange(self, (NameValueCollection)a); + } else { + throw new CheckException("JObject.Add:{0}={1} as key", t.Name, a); + } + } else { + self[key] = a.ToJToken(); + key = null; + } + } + if (key != null) + throw new CheckException("JObject.Add:No value supplied for key {0}", key); + return self; + } + + /// + /// Return this string, with first letter upper case + /// + public static string Capitalise(this string s) { + return string.IsNullOrEmpty(s) ? s : s.Substring(0, 1).ToUpper() + s.Substring(1); + } + + /// + /// Convert C# object to JToken + /// + public static JToken ToJToken(this object o) { + return o == null ? null : JToken.FromObject(o); + } + + /// + /// this[name] as a bool + /// + public static bool AsBool(this JObject self, string name) { + if (self == null) + return false; + JToken val = self[name]; + if(val == null || val.Type == JTokenType.Null) + return false; + string v = val.To().Trim().ToLower(); + switch (v) { + case "false": + case "0": + case "": + case "null": + case "undefined": + return false; + default: + return true; + } + } + + /// + /// this[name] as a DateTime + /// + public static DateTime AsDate(this JObject self, string name) { + return self[name].To(); + } + + /// + /// this[name] as a decimal + /// + public static decimal AsDecimal(this JObject self, string name) { + if (self == null) + return 0; + JToken val = self[name]; + return val == null || string.IsNullOrEmpty(val.To()) ? 0 : val.ToObject(); + } + + /// + /// this[name] as a double + /// + public static double AsDouble(this JObject self, string name) { + if (self == null) + return 0; + JToken val = self[name]; + return val == null || string.IsNullOrEmpty(val.To()) ? 0 : val.ToObject(); + } + + /// + /// this[name] as an int + /// + public static int AsInt(this JObject self, string name) { + if (self == null) + return 0; + JToken val = self[name]; + return val == null || string.IsNullOrEmpty(val.To()) ? 0 : val.ToObject(); + } + + /// + /// this[name] as a JObject + /// + public static JObject AsJObject(this JObject self, string name) { + if (self == null) + return null; + JToken val = self[name]; + return (JObject)val; + } + + /// + /// this[name] as a string + /// + public static string AsString(this JObject self, string name) { + if (self == null) + return null; + JToken val = self[name]; + return val == null ? null : val.ToObject(); + } + + public static T As(this JObject self, string name) where T:class { + if (self == null) + return null; + JToken val = self[name]; + return val == null ? null : val.ToObject(); + } + + /// + /// Convert this JObject to a C# object of type T + /// + public static T To(this JToken self) { + try { + return self.ToObject(_converter); + } catch (Exception ex) { + Match m = Regex.Match(ex.Message, "Error converting value (.*) to type '(.*)'. Path '(.*)', line"); + if (m.Success) + throw new CheckException(ex, "{0} is an invalid value for {1}", m.Groups[1], m.Groups[3]); + throw new CheckException(ex, "Could not convert {0} to {1}", self, typeof(T).Name); + } + + } + + /// + /// Assert condition is true, throw a CheckException if not. + /// + public static void Check(bool condition, string error) { + if (!condition) + throw new CheckException(error); + } + + /// + /// Assert condition is true, throw a CheckException if not. + /// + public static void Check(bool condition, string format, params object[] args) { + if (!condition) + throw new CheckException(format, args); + } + + /// + /// Regex to match decimals + /// + public static Regex DecimalRegex = new Regex(@"^[+-]?\d+(:?\.\d*)?$", RegexOptions.Compiled); + + /// + /// If s is a positive integer, return it, otherwise 0 + /// + public static int ExtractNumber(string s) { + if (string.IsNullOrEmpty(s)) + return 0; + Match m = Utils.InvoiceNumber.Match(s); + return m.Success ? int.Parse(m.Value) : 0; + } + + /// + /// Convert Url to a local file reference, and return a FileInfo for the file. + /// Throws if the file is not in WebFolder, or a subfolder thereof. + /// + public static FileInfo FileInfoForUrl(string url) { + if (url.StartsWith("/")) + url = url.Substring(1); + FileInfo f = new FileInfo(Path.Combine(AppSettings.Default.WebFolder, url)); + Utils.Check(f.FullName.StartsWith(new FileInfo(AppSettings.Default.WebFolder).FullName), "Illegal file access"); + return f; + } + + /// + /// Regex matches integers + /// + public static Regex IntegerRegex = new Regex(@"^[+-]?\d+$", RegexOptions.Compiled); + + /// + /// Regex matches positive integers + /// + public static Regex InvoiceNumber = new Regex(@"^\d+$", RegexOptions.Compiled); + + /// + /// True if all properties of this are null (or if this itself is null) + /// + public static bool IsAllNull(this JObject j) { + return j == null || j.PropertyValues().Where(v => v.Type != JTokenType.Null).FirstOrDefault() == null; + } + + public static bool IsDecimal(this string s) { + return s == null ? false : DecimalRegex.IsMatch(s); + } + + public static bool IsInteger(this string s) { + return s == null ? false : IntegerRegex.IsMatch(s); + } + + /// + /// Convert this json string to a C# object of type t. + /// + public static object JsonTo(this string s, Type t) { + return JsonConvert.DeserializeObject(s, t, new DecimalFormatJsonConverter()); + } + + /// + /// Convert this json string to a C# object of type T. + /// + public static T JsonTo(this string s) { + return JsonConvert.DeserializeObject(s, new DecimalFormatJsonConverter()); + } + + /// + /// Translate NameType letter to human-readable form + /// + public static string NameType(this string type) { + switch (type) { + case "C": + return "Customer"; + case "S": + return "Supplier"; + case "O": + return "Other Name"; + case "": + case null: + return "Unknown"; + default: + return "Type " + type; + } + } + + /// + /// Split text at the first supplied delimiter. + /// Return the text before the delimiter, and set text to the remainder. + /// + public static string NextToken(ref string text, params string[] delimiters) { + string[] parts = text.Split(delimiters, 2, StringSplitOptions.None); + text = parts.Length > 1 ? parts[1] : ""; + return parts[0]; + } + + /// + /// Time Zone to use throughout + /// + public static TimeZoneInfo _tz = TimeZoneInfo.Local; + + /// + /// For testing - set this to an offset, and all dates & times will be offset by this amount. + /// Enables a test to be run as if the computer time clock was offset by this amount - + /// i.e. the date & time were set exactly the same as when the test was first run. + /// + internal static TimeSpan _timeOffset = new TimeSpan(0); + + public static DateTime Now { + get { + return TimeZoneInfo.ConvertTime(DateTime.UtcNow + _timeOffset, _tz); + } + } + + public static DateTime Today { + get { + return Now.Date; + } + } + + /// + /// If s starts and ends with a double-quote ("), remove them + /// + public static string RemoveQuotes(string s) { + if (s.StartsWith("\"") && s.EndsWith("\"")) + s = s.Substring(1, s.Length - 2); + return s; + } + + /// + /// Compare 2 strings, and return a number between 0 & 1 indicating what proportion of the words were the same. + /// + public static float SimilarTo(this string self, string s) { + if (string.IsNullOrWhiteSpace(self) || string.IsNullOrWhiteSpace(s)) + return 0; + s = s.ToUpper(); + self = self.ToUpper(); + int tot = 0; + int matched = 0; + foreach (Match m in Regex.Matches(self, @"\w+")) { + tot += m.Length; + if (s.Contains(m.Value)) + matched += m.Length; + } + return tot == 0 ? 0 : (float)matched / tot; + } + + /// + /// Convert a C# object to json + /// + public static string ToJson(this object o) { + return JsonConvert.SerializeObject(o, new DecimalFormatJsonConverter()); + } + + /// + /// Convert a CamelCase variable name to human-readable form - e.g. "Camel Case". + /// Accepts an object, so you can use it directly on Enum values. + /// + public static string UnCamel(this object s) { + return UnCamel(s.ToString()); + } + + /// + /// Convert a CamelCase variable name to human-readable form - e.g. "Camel Case" + /// + public static string UnCamel(this string s) { + return Regex.Replace(s, "([A-Z])(?=[a-z0-9])", " $1").Trim(); + } + } + + public class CheckException : ApplicationException { + + public CheckException(string message) + : this(null, message) { + } + + public CheckException(string message, Exception ex) + : this(ex, message + "\r\n") { + } + + public CheckException(Exception ex, string message) + : base(message, ex) { + } + + public CheckException(string format, params object[] args) + : this(string.Format(format, args)) { + } + + public CheckException(Exception ex, string format, params object[] args) + : this(ex, string.Format(format + "\r\n", args)) { + } + } + + /// + /// Our own converter to/from decimal and float (and decimal? and float?). + /// Everything is rounded to 4 decimal places to get over floating point inaccuracies. + /// Converting to object, accepts strings, floats and ints. + /// + public class DecimalFormatJsonConverter : JsonConverter { + public DecimalFormatJsonConverter() { + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { + if (value == null) + writer.WriteValue((decimal?)null); + else { + if (value.GetType() == typeof(double) || value.GetType() == typeof(double?)) + writer.WriteValue(Math.Round((double)value, 4)); + else + writer.WriteValue(Math.Round((decimal)value, 4)); + } + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { + JToken token = JToken.Load(reader); + decimal d; + switch (token.Type) { + case JTokenType.Float: + case JTokenType.Integer: + d = token.ToObject(); + break; + case JTokenType.String: + string s = token.ToObject(); + d = string.IsNullOrWhiteSpace(s) ? 0 : decimal.Parse(s); + break; + case JTokenType.Null: + if (objectType == typeof(decimal?) || objectType == typeof(float?)) { + return null; + } + throw new JsonSerializationException("Value may not be null"); + default: + throw new JsonSerializationException("Unexpected token type: " + token.Type.ToString()); + } + d = Math.Round(d, 4); + if (objectType == typeof(decimal) || objectType == typeof(decimal?)) return d; + return (double)d; + } + + public override bool CanConvert(Type objectType) { + return objectType == typeof(decimal) || objectType == typeof(double) || objectType == typeof(decimal?) || objectType == typeof(double?); + } + } +} diff --git a/WebServer.cs b/WebServer.cs new file mode 100644 index 0000000..c7c0777 --- /dev/null +++ b/WebServer.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Net; +using System.Net.Http; +using System.Web; +using System.IO; +using System.Reflection; +using System.Runtime.Serialization.Json; +using Newtonsoft.Json.Linq; + +namespace AccountServer { + public class WebServer { + HttpListener _listener; + bool _running; + Dictionary _sessions; + static object _lock = new object(); + Session _empty; + + public WebServer() { + _listener = new HttpListener(); + _listener.Prefixes.Add("http://+:" + AppSettings.Default.Port + "/"); + Log("Listening on port {0}", AppSettings.Default.Port); + _sessions = new Dictionary(); + _empty = new Session(null); + // Start thread to expire sessions after 30 mins of inactivity + new Thread(new ThreadStart(delegate() { + for (; ; ) { + Thread.Sleep(180000); // 30 mins + DateTime now = Utils.Now; + lock (_sessions) { + foreach (string key in _sessions.Keys.ToList()) { + Session s = _sessions[key]; + if (s.Expires < now) + _sessions.Remove(key); + } + } + } + })) { IsBackground = true, Name = "SessionExpirer" }.Start(); + } + + static public void Log(string s) { + s = s.Trim(); + lock (_lock) { + System.Diagnostics.Trace.WriteLine(s); + Console.WriteLine(s); + } + } + + static public void Log(string format, params object[] args) { + try { + Log(string.Format(format, args)); + } catch (Exception ex) { + Log(string.Format("{0}:Error logging {1}", format, ex.Message)); + } + } + + public void Start() { + try { + _running = true; + _listener.Start(); + while (_running) { + try { + HttpListenerContext request = _listener.GetContext(); + ThreadPool.QueueUserWorkItem(ProcessRequest, request); + } catch { + } + } + } catch (HttpListenerException ex) { + Log(ex.ToString()); + } catch (ThreadAbortException) { + } catch (Exception ex) { + Log(ex.ToString()); + } + } + + public void Stop() { + _running = false; + _listener.Stop(); + } + + void ProcessRequest(object listenerContext) { + HttpListenerContext context = null; + AppModule module = null; + StringBuilder log = new StringBuilder(); + try { + context = (HttpListenerContext)listenerContext; + log.AppendFormat("{0}:", context.Request.RawUrl); + Session session = null; + string filename = HttpUtility.UrlDecode(context.Request.Url.AbsolutePath).Substring(1); + if (filename == "") filename = "company"; + string moduleName = null; + string methodName = null; + string baseName = filename.Replace(".html", ""); + if (baseName.IndexOf(".") < 0) { + // Urls of the form /ModuleName[/MethodName][.html] call a C# AppModule + string[] parts = baseName.Split('/'); + if (parts.Length <= 2) { + Type type = AppModule.GetModule(parts[0]); + if (type != null) { + // The AppModule exists - create the object + module = (AppModule)Activator.CreateInstance(type); + moduleName = parts[0]; + if (parts.Length == 2) methodName = parts[1]; + } + } + } + if (moduleName == null) { + // No AppModule found - treat url as a file request + moduleName = "FileSender"; + module = new FileSender(filename); + session = new Session(null); + } else { + // AppModule found - retrieve or create a session for it + Cookie cookie = context.Request.Cookies["session"]; + if (cookie != null) { + _sessions.TryGetValue(cookie.Value, out session); + if (AppSettings.Default.SessionLogging) + log.AppendFormat("[{0}{1}]", cookie.Value, session == null ? " not found" : ""); + } + if (session == null) { + session = new Session(this); + cookie = new Cookie("session", session.Cookie, "/"); + if (AppSettings.Default.SessionLogging) + log.AppendFormat("[{0} new session]", cookie.Value); + } + context.Response.Cookies.Add(cookie); + cookie.Expires = session.Expires = Utils.Now.AddHours(1); + } + module.Session = session; + module.LogString = log; + if (moduleName.EndsWith("Module")) + moduleName = moduleName.Substring(0, moduleName.Length - 6); + using (module) { + module.Call(context, moduleName, methodName); + } + } catch (Exception ex) { + while (ex is TargetInvocationException) + ex = ex.InnerException; + log.AppendFormat("Request error: {0}\r\n", ex); + if (module == null || !module.ResponseSent) { + try { + module = new AppModule(); + module.Session = _empty; + module.LogString = log; + module.Context = context; + module.Module = "exception"; + module.Method = "default"; + module.Title = "Exception"; + module.Exception = ex; + module.WriteResponse(module.Template("exception", module), "text/html", HttpStatusCode.InternalServerError); + } catch (Exception ex1) { + log.AppendFormat("Error displaying exception: {0}\r\n", ex1); + if (module == null || !module.ResponseSent) { + try { + module.WriteResponse("Error displaying exception:" + ex.Message, "text/plain", HttpStatusCode.InternalServerError); + } catch { + } + } + } + } + } + try { + Log(log.ToString()); + } catch { + } + if (context != null) + context.Response.Close(); + } + + public class BaseSession { + public JObject Object { get; private set; } + public DateTime Expires; + public string Cookie { get; private set; } + + public BaseSession(WebServer server) { + if (server != null) { + Session session; + Random r = new Random(); + + lock (server._sessions) { + do { + Cookie = ""; + for (int i = 0; i < 20; i++) + Cookie += (char)('A' + r.Next(26)); + } while (server._sessions.TryGetValue(Cookie, out session)); + Object = new JObject(); + server._sessions[Cookie] = (Session)this; + } + } + } + } + } + +} diff --git a/html/Query/default.html b/html/Query/default.html new file mode 100644 index 0000000..907d015 --- /dev/null +++ b/html/Query/default.html @@ -0,0 +1,19 @@ + + + + +
+
+ diff --git a/html/accounting/default.html b/html/accounting/default.html new file mode 100644 index 0000000..1beda58 --- /dev/null +++ b/html/accounting/default.html @@ -0,0 +1,33 @@ + + + + + + +
+
+ + \ No newline at end of file diff --git a/html/accounting/detail.html b/html/accounting/detail.html new file mode 100644 index 0000000..8744384 --- /dev/null +++ b/html/accounting/detail.html @@ -0,0 +1,78 @@ +{{#with Record}} + + {{/with}} + + +
+
+ diff --git a/html/accounting/document.html b/html/accounting/document.html new file mode 100644 index 0000000..93144c6 --- /dev/null +++ b/html/accounting/document.html @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + +{{#with Record}} + +{{/with}} + + +
+
+ diff --git a/html/accounting/test.js b/html/accounting/test.js new file mode 100644 index 0000000..eab140f --- /dev/null +++ b/html/accounting/test.js @@ -0,0 +1,23 @@ +/** + * Created by Nikki on 06/11/2015. + */ +function makeForm(selector, options) { + //... + $('body').on('change', 'mytable :input', function() { + // Inspector complains "Argument type makeForm is not assignable to parameter type jElement" + inputValue(this); + }); + //... +} + +/** + * inputValue function + * @param {jElement} field + * @returns {string} + */ +function inputValue(field) { + //... + return ''; +} + +makeForm('', ''); \ No newline at end of file diff --git a/html/accounting/vatreturn.html b/html/accounting/vatreturn.html new file mode 100644 index 0000000..dc60ecb --- /dev/null +++ b/html/accounting/vatreturn.html @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + {{#with Record.return}}

Value Added Tax Return

+ + + + + + + + + +
Registration Number
{{Settings.VatRegistration}}
Name
{{Settings.CompanyName}}
Reporting Period
From: {{Start:d}} To: {{End:d}}
Due Date
{{Due:d}}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VAT due in this period on sales and other outputs 1{{Sales.Vat:0.00}}
VAT due in this period on acquisitions from other EC member states 20.00
Total VAT due (the sum of boxes 1 and 2)3{{Sales.Vat:0.00}}
VAT reclaimed in this period on purchases and other inputs 4{{Purchases.Vat:0.00}}
Net VAT to be paid to Customs or reclaimed by you5{{ToPay:0.00}}
 
Total value of sales and all other outputs excluding any VAT6{{Sales.Net:0}}
Total value of purchases and all other inputs excluding any VAT7{{Purchases.Net:0}}
 
Total value of all supplies of goods and related services to other EC Member States80
Total value of all acquisitions of goods and related services to other EC Member States90
{{/with}} +
+ diff --git a/html/admin/batch.html b/html/admin/batch.html new file mode 100644 index 0000000..8c8e219 --- /dev/null +++ b/html/admin/batch.html @@ -0,0 +1,30 @@ + + + + + + +
+
+ + \ No newline at end of file diff --git a/html/admin/default.html b/html/admin/default.html new file mode 100644 index 0000000..aef2f85 --- /dev/null +++ b/html/admin/default.html @@ -0,0 +1,135 @@ + + + {{#with Record}} + + {{/with}} + + +
+ diff --git a/html/admin/import.html b/html/admin/import.html new file mode 100644 index 0000000..f3e9bc9 --- /dev/null +++ b/html/admin/import.html @@ -0,0 +1,15 @@ +
+ + + + +
File to import
Date Format
+ + +
+
+ diff --git a/html/admin/importhelp.html b/html/admin/importhelp.html new file mode 100644 index 0000000..8b9bb69 --- /dev/null +++ b/html/admin/importhelp.html @@ -0,0 +1,30 @@ +

You can import any of the types of comma-delimited or tab-delimited files listed below.

+

If the system returns a message "No importer for file", this means the file does not +have all the fields listed below.

+

You can also import QIF files - but you should have transactions for all your accounts in a +single file, because if you use a separate file for each account, transfers between accounts in +different files will appear twice.

+

Importing from Quick Books

+

Quick books does not show the full name of sub-accounts in its reports. It +is therefore essential that you edit any subaccounts to give them unique names. +E.g. rename the Taxes subaccount under Payroll to Payroll/Taxes.

+

To transfer data from Quick Books, you only need 2 files.

+

Use File, Utilites, Export to produce an IIF File - if you tick all the boxes, +you can create a single file containing most of your data. The only other data +you will need is a Custom Transaction Detail Report for the transactions. Customize the report, +make sure all the fields listed below are selected, select All dates, run the report and then +print it to a tab-delimited file.

+

When importing, import your IIF Import File first, then go to Admin, Settings +in this accounts package, and make sure everything is filled in, especially your financial year +start - this is vital to ensure your VAT payments are matched correctly.

+

Finally, import your Transaction Detail Report.

+

Note that there is currently no way of importing payment history from Quick Books.

+ + + {{#each Importers}} + {{#each Fields}} + + {{/each}} + + {{/each}} +
Report typeField name
{{{Name}}}{{{TheirName}}}
\ No newline at end of file diff --git a/html/admin/integritycheck.html b/html/admin/integritycheck.html new file mode 100644 index 0000000..fab9c3b --- /dev/null +++ b/html/admin/integritycheck.html @@ -0,0 +1,3 @@ +{{#each Record}} +
{{this}}
+{{/each}} \ No newline at end of file diff --git a/html/admin/restore.html b/html/admin/restore.html new file mode 100644 index 0000000..9ac8a6b --- /dev/null +++ b/html/admin/restore.html @@ -0,0 +1,7 @@ +

Warning: This will replace all your data with the backup

+

Any changes made since the backup was taken will be lost.

+
+ File to restore: + + +
diff --git a/html/banking/default.html b/html/banking/default.html new file mode 100644 index 0000000..2876b29 --- /dev/null +++ b/html/banking/default.html @@ -0,0 +1,32 @@ + + + + + + +
+
+ + \ No newline at end of file diff --git a/html/banking/detail.html b/html/banking/detail.html new file mode 100644 index 0000000..d9f0ba3 --- /dev/null +++ b/html/banking/detail.html @@ -0,0 +1,120 @@ +{{#with Record}} + + {{/with}} + + +
Balance{{Record.Balance:0.00}}Current balance{{Record.CurrentBalance:0.00}}
+
+
+ diff --git a/html/banking/document.html b/html/banking/document.html new file mode 100644 index 0000000..f8c994f --- /dev/null +++ b/html/banking/document.html @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + +{{#with Record}} + +{{/with}} + + +
+
+ diff --git a/html/banking/importhelp.html b/html/banking/importhelp.html new file mode 100644 index 0000000..ec8c8c1 --- /dev/null +++ b/html/banking/importhelp.html @@ -0,0 +1,22 @@ +

Statement Format tells the system how to recognise a single line of a pasted statement.

+

Fields and delimiters are indicated by a word in {curly brackets}.

+ + + + + + + + + + + + + +
Field or delimiterDescription
{Date}Document date (required)
{Id}Document Identifier (e.g. cheque number)
{Name}Document Name (required)
{Memo}Document memo - if this is not present, the Name will be used for the memo.
{Amount}Amount (for statements which do not list payments and deposits separately). If a minus sign, or the letters "CR" are present, a deposit is assumed.
{Payment}Payment (e.g. cheque) amount (for statements which list payments and deposits separately).
{Deposit}Deposit amount (for statements which list payments and deposits separately).
{Any}Use as a placeholder to indicate any irrelevant information.
{Optional:Anything here}If there are items which appear on some lines but not others, place them in Optional, between the colon (':') and close curly bracket ('}'). + N.B. You cannot use curly brackets here, except {Tab}, {Newline} and {Any}.
{Tab}Tab character, or end of table column.
{Newline}New line, or end of table row.
+

Examples:

+ + + +
Barclaycard{Date}{Newline}{Name}{Tab}{Any}{Tab}{Any}{Tab}{Any}{Tab}{Newline}{Any}{Newline}{Amount}{Newline}
Coop bank{Date}{Tab}{Name}{Tab}{Deposit}{Tab}{Payment}{Optional:{Tab}{Any}}{Newline}
\ No newline at end of file diff --git a/html/banking/name.html b/html/banking/name.html new file mode 100644 index 0000000..62fe424 --- /dev/null +++ b/html/banking/name.html @@ -0,0 +1,45 @@ + + + + +
+ \ No newline at end of file diff --git a/html/banking/names.html b/html/banking/names.html new file mode 100644 index 0000000..d074ae1 --- /dev/null +++ b/html/banking/names.html @@ -0,0 +1,34 @@ + + + + + + +
+ + \ No newline at end of file diff --git a/html/banking/reconcile.html b/html/banking/reconcile.html new file mode 100644 index 0000000..2aa6bdd --- /dev/null +++ b/html/banking/reconcile.html @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + +{{#with Record}} + +{{/with}} + + +
+
+ + + diff --git a/html/banking/statementimport.html b/html/banking/statementimport.html new file mode 100644 index 0000000..09f5ae0 --- /dev/null +++ b/html/banking/statementimport.html @@ -0,0 +1,38 @@ + + + + +
+ + + + + + +
Paste statement data here
Statement Format
Or import a QIF file
Date Format
+ + +
+
+ + \ No newline at end of file diff --git a/html/banking/statementmatching.html b/html/banking/statementmatching.html new file mode 100644 index 0000000..5e80f78 --- /dev/null +++ b/html/banking/statementmatching.html @@ -0,0 +1,173 @@ +{{#with Record}} + + + {{/with}} + + +
+
+ diff --git a/html/banking/transfer.html b/html/banking/transfer.html new file mode 100644 index 0000000..49c7ff8 --- /dev/null +++ b/html/banking/transfer.html @@ -0,0 +1,83 @@ + + +{{#with Record}} + +{{/with}} + + +
+ diff --git a/html/bowser.js b/html/bowser.js new file mode 100644 index 0000000..6f38899 --- /dev/null +++ b/html/bowser.js @@ -0,0 +1,239 @@ +/*! + * Bowser - a browser detector + * https://github.com/ded/bowser + * MIT License | (c) Dustin Diaz 2014 + */ + +!function (name, definition) { + if (typeof module != 'undefined' && module.exports) module.exports['browser'] = definition(); + else if (typeof define == 'function') define(definition) + else this[name] = definition() +}('bowser', function () { + /** + * See useragents.js for examples of navigator.userAgent + */ + + var t = true + + function detect(ua) { + + function getFirstMatch(regex) { + var match = ua.match(regex); + return (match && match.length > 1 && match[1]) || ''; + } + + var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase() + , likeAndroid = /like android/i.test(ua) + , android = !likeAndroid && /android/i.test(ua) + , versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i) + , tablet = /tablet/i.test(ua) + , mobile = !tablet && /[^-]mobi/i.test(ua) + , result + + if (/opera|opr/i.test(ua)) { + result = { + name: 'Opera' + , opera: t + , version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i) + } + } + else if (/windows phone/i.test(ua)) { + result = { + name: 'Windows Phone' + , windowsphone: t + , msie: t + , version: getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i) + } + } + else if (/msie|trident/i.test(ua)) { + result = { + name: 'Internet Explorer' + , msie: t + , version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i) + } + } + else if (/chrome|crios|crmo/i.test(ua)) { + result = { + name: 'Chrome' + , chrome: t + , version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i) + } + } + else if (iosdevice) { + result = { + name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod' + } + // WTF: version is not part of user agent in web apps + if (versionIdentifier) { + result.version = versionIdentifier + } + } + else if (/sailfish/i.test(ua)) { + result = { + name: 'Sailfish' + , sailfish: t + , version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i) + } + } + else if (/seamonkey\//i.test(ua)) { + result = { + name: 'SeaMonkey' + , seamonkey: t + , version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i) + } + } + else if (/firefox|iceweasel/i.test(ua)) { + result = { + name: 'Firefox' + , firefox: t + , version: getFirstMatch(/(?:firefox|iceweasel)[ \/](\d+(\.\d+)?)/i) + } + if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) { + result.firefoxos = t + } + } + else if (/silk/i.test(ua)) { + result = { + name: 'Amazon Silk' + , silk: t + , version : getFirstMatch(/silk\/(\d+(\.\d+)?)/i) + } + } + else if (android) { + result = { + name: 'Android' + , version: versionIdentifier + } + } + else if (/phantom/i.test(ua)) { + result = { + name: 'PhantomJS' + , phantom: t + , version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i) + } + } + else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) { + result = { + name: 'BlackBerry' + , blackberry: t + , version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i) + } + } + else if (/(web|hpw)os/i.test(ua)) { + result = { + name: 'WebOS' + , webos: t + , version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i) + }; + /touchpad\//i.test(ua) && (result.touchpad = t) + } + else if (/bada/i.test(ua)) { + result = { + name: 'Bada' + , bada: t + , version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i) + }; + } + else if (/tizen/i.test(ua)) { + result = { + name: 'Tizen' + , tizen: t + , version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier + }; + } + else if (/safari/i.test(ua)) { + result = { + name: 'Safari' + , safari: t + , version: versionIdentifier + } + } + else result = {} + + // set webkit or gecko flag for browsers based on these engines + if (/(apple)?webkit/i.test(ua)) { + result.name = result.name || "Webkit" + result.webkit = t + if (!result.version && versionIdentifier) { + result.version = versionIdentifier + } + } else if (!result.opera && /gecko\//i.test(ua)) { + result.name = result.name || "Gecko" + result.gecko = t + result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i) + } + + // set OS flags for platforms that have multiple browsers + if (android || result.silk) { + result.android = t + } else if (iosdevice) { + result[iosdevice] = t + result.ios = t + } + + // OS version extraction + var osVersion = ''; + if (iosdevice) { + osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i); + osVersion = osVersion.replace(/[_\s]/g, '.'); + } else if (android) { + osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i); + } else if (result.windowsphone) { + osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i); + } else if (result.webos) { + osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i); + } else if (result.blackberry) { + osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i); + } else if (result.bada) { + osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i); + } else if (result.tizen) { + osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i); + } + if (osVersion) { + result.osversion = osVersion; + } + + // device type extraction + var osMajorVersion = osVersion.split('.')[0]; + if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) { + result.tablet = t + } else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) { + result.mobile = t + } + + // Graded Browser Support + // http://developer.yahoo.com/yui/articles/gbs + if ((result.msie && result.version >= 10) || + (result.chrome && result.version >= 20) || + (result.firefox && result.version >= 20.0) || + (result.safari && result.version >= 6) || + (result.opera && result.version >= 10.0) || + (result.ios && result.osversion && result.osversion.split(".")[0] >= 6) + ) { + result.a = t; + } + else if ((result.msie && result.version < 10) || + (result.chrome && result.version < 20) || + (result.firefox && result.version < 20.0) || + (result.safari && result.version < 6) || + (result.opera && result.version < 10.0) || + (result.ios && result.osversion && result.osversion.split(".")[0] < 6) + ) { + result.c = t + } else result.x = t + + return result + } + + var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '') + + + /* + * Set our detect method to the main bowser object so we can + * reuse it to test other user agents. + * This is needed to implement future tests. + */ + bowser._detect = detect; + + return bowser +}); \ No newline at end of file diff --git a/html/company/default.html b/html/company/default.html new file mode 100644 index 0000000..fdb5b9e --- /dev/null +++ b/html/company/default.html @@ -0,0 +1,135 @@ + + + + + +

To do

+
+

Banking

+
+{{#if Record.investments.Count}} +

Investments

+
+{{/if}} +{{#if Record.customer.Count}} +

Customers

+
+{{/if}} +{{#if Record.supplier.Count}} +

Suppliers

+
+{{/if}} +

Net Worth : {{NetWorth:#,##0.00}}

+ \ No newline at end of file diff --git a/html/company/job.html b/html/company/job.html new file mode 100644 index 0000000..46df197 --- /dev/null +++ b/html/company/job.html @@ -0,0 +1,83 @@ + + + + +
+
+ diff --git a/html/company/schedule.html b/html/company/schedule.html new file mode 100644 index 0000000..5874dad --- /dev/null +++ b/html/company/schedule.html @@ -0,0 +1,22 @@ + + + + +
+ diff --git a/html/customer/default.html b/html/customer/default.html new file mode 100644 index 0000000..ca39de1 --- /dev/null +++ b/html/customer/default.html @@ -0,0 +1,33 @@ + + + + +
+ diff --git a/html/customer/detail.html b/html/customer/detail.html new file mode 100644 index 0000000..4cc9944 --- /dev/null +++ b/html/customer/detail.html @@ -0,0 +1,79 @@ + + + + + + +
Phone{{Record.Telephone}}Email{{Record.Email}}Contact{{Record.Contact}}Outstanding{{Record.Outstanding:0.00}}
+
+
+ \ No newline at end of file diff --git a/html/customer/document.html b/html/customer/document.html new file mode 100644 index 0000000..ec02f9a --- /dev/null +++ b/html/customer/document.html @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + +{{#with Record}} + +{{/with}} + + +
+
+ diff --git a/html/customer/email.txt b/html/customer/email.txt new file mode 100644 index 0000000..09374a8 --- /dev/null +++ b/html/customer/email.txt @@ -0,0 +1,5 @@ +{{#with Record}}{{Settings.CompanyName}} {{header.DocType}} {{header.DocumentIdentifier}} +Dear {{customer.Contact}}, + +Attached please find our {{header.doctype}} for {{header.DocumentDate:d}}. +{{/with}} diff --git a/html/customer/payment.html b/html/customer/payment.html new file mode 100644 index 0000000..25e2910 --- /dev/null +++ b/html/customer/payment.html @@ -0,0 +1,164 @@ + + +{{#with Record}} + +{{/with}} + + +
+
+ diff --git a/html/customer/paymenthistory.html b/html/customer/paymenthistory.html new file mode 100644 index 0000000..40a91f7 --- /dev/null +++ b/html/customer/paymenthistory.html @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + +{{#with Record}} + +{{/with}} + + +
+
+ diff --git a/html/customer/print.html b/html/customer/print.html new file mode 100644 index 0000000..e0e4462 --- /dev/null +++ b/html/customer/print.html @@ -0,0 +1,136 @@ + + + + + {{{Record.header.DocType}}} {{{Record.header.DocumentIdentifier}}} + + + + +
+ + + + + +
+
Invoice to:
+
{{{Record.header.DocumentAddress}}}
+
+
{{{Settings.CompanyName}}}
+ + + + + + + + + +
Address:{{{Settings.CompanyAddress}}}
Phone:{{{Settings.CompanyPhone}}}
Email:{{{Settings.CompanyEmail}}}
WebSite:{{{Settings.WebSite}}}
VAT Reg no:{{{Settings.VatRegistration}}}
Company no:{{{Settings.CompanyNumber}}}
{{{Record.header.DocType}}} no:{{{Record.header.DocumentIdentifier}}}
Tax Date:{{{Record.header.DocumentDate:D}}}
+
+
{{{Record.header.DocumentMemo}}}
+ + + + + + + + + + + + + + {{#each Record.detail}} + + + + + + + + + + {{/each}} + + + + + + + + + + + +
QtyMemoUnit PriceVat CodeVatVat RateNet
{{{Qty}}}{{{Memo}}}{{{UnitPrice:0.00}}}{{{Code}}}{{{VatAmount:0.00}}}{{{VatRate}}}{{{LineAmount:0.00}}}
Total Net + {{{Record.TotalNet:0.00}}} +
Total Vat{{{Record.TotalVat:0.00}}}
{{{Record.header.DocType}}} Total + {{{Record.Total:0.00}}} +
+
+ + \ No newline at end of file diff --git a/html/customer/product.html b/html/customer/product.html new file mode 100644 index 0000000..8b52227 --- /dev/null +++ b/html/customer/product.html @@ -0,0 +1,56 @@ + + + + +
+
+ diff --git a/html/customer/products.html b/html/customer/products.html new file mode 100644 index 0000000..343f0c0 --- /dev/null +++ b/html/customer/products.html @@ -0,0 +1,25 @@ + + + + + + +
+ + \ No newline at end of file diff --git a/html/customer/vatcode.html b/html/customer/vatcode.html new file mode 100644 index 0000000..7e273b7 --- /dev/null +++ b/html/customer/vatcode.html @@ -0,0 +1,33 @@ + + + + +
+ \ No newline at end of file diff --git a/html/customer/vatcodes.html b/html/customer/vatcodes.html new file mode 100644 index 0000000..90e8d52 --- /dev/null +++ b/html/customer/vatcodes.html @@ -0,0 +1,22 @@ + + + + + + +
+ + \ No newline at end of file diff --git a/html/default.css b/html/default.css new file mode 100644 index 0000000..28d7ca8 --- /dev/null +++ b/html/default.css @@ -0,0 +1,222 @@ +#header { + display: none; + margin: -8px -8px -8px -8px; + padding: 0; +} +#spacer { + display: none; +} +#body { + overflow: auto; +} +#menuicon { + display: block; + position: fixed; + width: 32px; + height: 32px; + top: 0; + right: 0; +} +#heading { + overflow: hidden; + color: white; + font-weight: bold; + background-color: #4970D1; + text-align: center; + margin: 0; + padding: 2px; +} +#menu1 { + min-height: 31px; + background-color: lightgrey; + padding: 2px; +} +#menu2 { + min-height: 31px; + background-color: lightblue; + padding: 2px; +} +#menu3 { + min-height: 31px; + background-color: lightgreen; + padding: 2px; +} +@media screen and (min-width:700px) { + #spacer { + display: block; + height: 120px; + width:100%; + } + #menuicon { + display: none; + } + #header { + display: block; + position: fixed; + top: 0; + width: 100%; + margin: 0 -8px -8px -8px; + } + #menu1 { + width:100%; + } + #menu2 { + width:100%; + } + #menu3 { + width:100%; + } + #body { + width: 100%; + } +} +#message { + clear: both; + color: red; + font-weight: bold; +} +.warning { + color: red; + font-weight: bold; +} +.header { + font-weight: bold; +} +.title { + font-weight: bold; + text-decoration: underline; +} +.total { + font-weight: bold; +} +tr.total td.n:not(:empty) { + border-top: 1px solid black; + border-bottom: 3px double black; +} +tr.totalSpacer { + height: 10px; +} +div.buttons { + padding: 5px; +} +th, td { + vertical-align: top; + margin-top: 5px; + margin-bottom: 5px; +} +th { + text-align: left; +} +td.n, th.n, td.ni, th.ni { + text-align: right; +} +span.t { + color: transparent; +} +th.ni { + padding-right: 16px; +} +table.listform tr td { + vertical-align: middle; +} +.dataTables_wrapper { + margin-top: 10px; + margin-bottom: 10px; +} +.dataTables_wrapper>button { + margin-left: 10px; +} +input[type="number"] { + text-align: right; + width: 5em; +} +button { + margin: 5px; +} +button.highlight { + font-weight: bold; + border-width: 2px; + background-color: lightgray; +} +button.nextButton { + margin: 0 5px; +} +div.fixedTable { + position: fixed; + left: 10px; + right: 10px; + bottom: 10px; +} +button.deleteButton { + padding: 0; + margin: 0; + height: 18px; + width: 18px; + position: relative; + top: 2px; +} +tr.noDeleteButton button.deleteButton { + visibility: hidden; +} +tr.selected { + background-color: #b0bed9 !important; +} +tr.current { + background-color: green !important; +} +tr.future { + color: orange; +} +tr.matches { + color: red; +} +tr.r2 { + color: darkblue; +} +td.ch { + background-color: yellow !important; +} +.ui-autocomplete-category { + font-weight: bold; +} +.noselect { + -webkit-user-select: none; /* Chrome/Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+ */ + -o-user-select: none; + user-select: none; +} +#reportDates { + font-weight: bold; + margin-left: 5px; + margin-bottom: 0.5cm; +} +input::-webkit-inner-spin-button, +input::-webkit-outer-spin-button { + -webkit-appearance: none; +} +@media print { + #heading { + color: black; + background-color: transparent; + } + #menu1, #menu2, #menu3, #message { + display: none; + } + #menuicon { + display: none; + } + #body { + overflow: visible; + height: auto; + } + #spacer { + height: 30px; + } + .noprint { + display: none; + } + button.deleteButton { + display: none; + } +} \ No newline at end of file diff --git a/html/default.html b/html/default.html new file mode 100644 index 0000000..c54c2be --- /dev/null +++ b/html/default.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + {{Config.Filename}} - {{Settings.CompanyName}} - {{{Title}}} + {{Head}} +{{#if Record}} + +{{/if}} + + + + +
+
+
{{Message}} 
+{{Body}} +
+ + \ No newline at end of file diff --git a/html/default.js b/html/default.js new file mode 100644 index 0000000..3035e82 --- /dev/null +++ b/html/default.js @@ -0,0 +1,2860 @@ +var unsavedInput; // True if an edit field has been changed, and not saved +// var testHarness; // True if running in Firefox (used for automated tests) +var touchScreen; // True if running on a tablet or phone +var decPoint; // The decimal point character of this locale +// Extend auto-complete widget to cope with multiple categories +$.widget( "custom.catcomplete", $.ui.autocomplete, { + _create: function() { + this._super(); + this.widget().menu( "option", "items", "> :not(.ui-autocomplete-category)" ); + }, + _renderMenu: function( ul, items ) { + var that = this, + currentCategory = ""; + $.each( items, function( index, item ) { + var li; + if ( item.category != currentCategory ) { + ul.append( "
  • " + item.category + "
  • " ); + currentCategory = item.category; + } + li = that._renderItemData( ul, item ); + if ( item.category ) { + li.attr( "aria-label", item.category + " : " + item.label ); + } + }); + } +}); + +/** + * Account types - should correcpond to AcctType enum in C# + * @enum {number} + */ +var AcctType = { + Income:1, + Expense:2, + Security:3, + OtherIncome:4, + OtherExpense:5, + FixedAsset:6, + OtherAsset:7, + AccountsReceivable:8, + Bank:9, + Investment:10, + OtherCurrentAsset:11, + CreditCard:12, + AccountsPayable:13, + OtherCurrentLiability:14, + LongTermLiability:15, + OtherLiability:16, + Equity:17 +}; +//noinspection JSUnusedGlobalSymbols +/** + * Fixed G/L accounts - should correspond to Acct enum in C# + * @enum {number} + */ +var Acct = { + SalesLedger:1, + PurchaseLedger:2, + OpeningBalEquity:3, + RetainedEarnings:4, + ShareCapital:5, + UndepositedFunds:6, + UninvoicedSales:7, + VATControl:8 +}; +/** + * Document types - should correspond to DocType enum in C# + * @enum {number} + */ +var DocType = { + Invoice:1, + Payment:2, + CreditMemo:3, + Bill:4, + BillPayment:5, + Credit:6, + Cheque:7, + Deposit:8, + CreditCardCharge:9, + CreditCardCredit:10, + GeneralJournal:11, + Transfer:12, + OpeningBalance:13, + Buy:14, + Sell:15, + Gain:16, + Loss:17 +}; + +$(function() { +// testHarness = bowser.firefox && hasParameter('test'); + touchScreen = bowser.mobile || bowser.tablet; + decPoint = (1.23).toLocaleString()[1]; + resize(); + $('#menuicon').click(function() { + // Small screen user has clicked menu icon - show/hide menu + $('#header').slideToggle(); + }); + $('body').on('click', 'button[href]', function() { + // Buttons with hrefs act like links + window.location = $(this).attr('href'); + }); + $('body').on('click', 'button[data-goto]', function() { + // Buttons with data-goto act like links, but also store state to come back to + goto($(this).attr('data-goto')); + }); + $(window).bind('beforeunload', function () { + // Warn user if there is unsaved data on leaving the page + if(unsavedInput) return "There is unsaved data"; + }); + $(window).on('resize', resize); + $(window).on('unload', function() { + // Disable input fields & buttons on page unload + message("Please wait..."); + $(':input').prop('disabled', true); + }); + $('body').on('change', ':input:not(.nosave)', function() { + // User has changed an input field - set unsavedInput (except for dataTable search field) + if(!$(this).attr('aria-controls')) + unsavedInput = true; + }); + $('button[type="submit"]').click(function() { + // Submit button will presumably save the input + message("Please wait..."); + unsavedInput = false; + }); + $('body').on('click', 'button.reset', function() { + // For when an ordinary reset button won't do any calculated data + window.location.reload(); + }); + $('body').on('click', 'button.cancel', goback); + $('body').on('click', 'button.nextButton', function() { + // Button to set document number field to , so C# will fill it in with the next one. + $(this).prev('input[type="text"]').val('').trigger('change'); + }); + if(!touchScreen) { + // Moving focus to a field selects the contents (except on touch screens) + var focusInput; + $('body').on('focus', ':input', function () { + focusInput = this; + $(this).select(); + }).on('mouseup', ':input', function (e) { + if(focusInput == this) { + focusInput = null; + e.preventDefault(); + } + }); + } + var components = window.location.pathname.split('/'); + // Highlight top level menu item corresponding to current module + $('#menu1 button[href="/' + components[1] + '/default.html"]').addClass('highlight'); + // Highlight second level menu item corresponding to current url + $('#menu2 button').each(function() { + var href = $(this).attr('href'); + if(href == window.location.pathname + window.location.search) + $(this).addClass('highlight'); + }); + setTimeout(function() { + // Once initial form creation is done: + // add a Back button if there isn't one + if (/[^\/]\//.test(window.location.pathname) && $('button#Back').length == 0) + actionButton('Back').click(goback); + // Focus to the first input field + var focusField = $(':input[autofocus]:enabled:visible:first'); + if (focusField.length == 0) + focusField = $(':input:enabled:visible:not(button):first'); + focusField.focus().select(); + }, 100); +}); + +/** + * Add a goto button to menu 2 + * @param text Button text + * @param url Url to go to + * @returns {*|jQuery} Button + */ +function addButton(text, url) { + return $('') + .text(text) + .appendTo($('#menu2')); +} + +/** + * Add a link button to menu 2 + * @param text Button text + * @param url Url to link to + * @returns {*|jQuery} Button + */ +function jumpButton(text, url) { + return $('') + .text(text) + .appendTo($('#menu2')); +} + +/** + * Add a button to menu 3 + * @param text Button text + * @returns {*|jQuery} Button + */ +function actionButton(text) { + return $('') + .text(text) + .appendTo($('#menu3')); +} + +/** + * Show message at top of screen + * @param m message + */ +function message(m) { + if(m) $('#message').text(m); + else $('#message').html(' '); +} + +/** + * When user clicks on an item in a list, open it + * @param data for item - must contain idAccount and idAccountType + * @returns {boolean} true if there was somewhere to go to + */ +function openDetail(data) { + var url = detailUrl(data); + if(url) + goto(url); + else + return false; +} + +/** + * When user clicks on a document in a list, open it + * @param data for item - must contain idDocument and DocumentTypeId + * @param acct Parent account (e.g. bank account) if relevant + * @returns {boolean} true if there was somewhere to go to + */ +function openDocument(data, acct) { + var url = documentUrl(data); + if(url) + goto(url + '&acct=' + acct); + else + return false; +} + +/** + * Work out the url to go to when someone clicks on an item in a list + * @param data for item - must contain idAccount and idAccountType + * @param {number} data.idAccount + * @param {number} data.idAccountType + * @returns {*} [url] (or null) + */ +function detailUrl(data) { + var url; + if(!data || !data.idAccount) + return; + switch(data.idAccountType) { + case AcctType.Bank: + case AcctType.CreditCard: + url = '/banking'; + break; + case AcctType.Investment: + url = '/investments'; + break; + case '': + case undefined: + case null: + case 0: + return; + default: + url = '/accounting'; + break; + } + return url + '/detail.html?id=' + data.idAccount; +} + +/** + * Work out the url to go to when someone clicks on an document in a list + * @param data for item - must contain idDocument and DocumentTypeId + * @param {number} data.idDocument + * @param {number} data.DocumentTypeId + * @returns {*} [url] (or null) + */ +function documentUrl(data) { + var s; + if(!data) + return; + switch(data.DocumentTypeId) { + case DocType.Invoice: + case DocType.CreditMemo: + s = '/customer/document'; + break; + case DocType.Payment: + s = '/customer/payment'; + break; + case DocType.Bill: + case DocType.Credit: + s = '/supplier/document'; + break; + case DocType.BillPayment: + s = '/supplier/payment'; + break; + case DocType.Cheque: + case DocType.Deposit: + case DocType.CreditCardCharge: + case DocType.CreditCardCredit: + s = '/banking/document'; + break; + case DocType.GeneralJournal: + s = '/Accounting/document'; + break; + case DocType.Transfer: + s = '/banking/transfer'; + break; + case DocType.Buy: + case DocType.Sell: + s = '/investments/document'; + break; + default: + return; + } + return s + '.html?id=' + data.idDocument + "&type=" + data.DocumentTypeId; +} + +/** + * Layout the window after a resize + */ +function resize() { + var top = $('#header').height(); + // A small screen - should match "@media screen and (min-width:700px)" in default.css + var auto = $(window).width() < 700; + $('#spacer').css('height', auto ? '' : top + 'px'); + $('#body').css('height', auto ? '' : ($(window).height() - top - 16) + 'px'); +} + +/** + * Parse a decimal number (up to 2 places). + * @param n Number string + * @returns {*} Formatted number, or n if n is null, empty or 0 + */ +function parseNumber(n) { + if(!n) + return n; + if(!/^[+-]?\d+(\.\d{1,2})?$/.test(n)) + throw n + ' is not a number'; + return parseFloat(n); +} + +/** + * Parse a double number (up to 4 places). + * @param n Number string + * @returns {*} Formatted number, or n if n is null, empty or 0 + */ +function parseDouble(n) { + if(!n) + return n; + if(!/^[+-]?\d+(\.\d{1,4})?$/.test(n)) + throw n + ' is not a number'; + return parseFloat(n); +} + +/** + * Parse an intefer number. + * @param n Number string + * @returns {*} Formatted number, or n if n is null, empty or 0 + */ +function parseInteger(n) { + if(!n) + return n; + if(!/^[+-]?\d+$/.test(n)) + throw n + ' is not a whole number'; + return parseInt(n); +} + +/** + * Parse a date. + * @param {string} date + * @returns {Date|string} Date, or argument if it is null or empty + */ +function parseDate(date) { + if(!date) + return date; + try { + return new Date(Date.parse(date)); + } catch(e) { + return date; + } +} + +/** + * Format a date into local format. + * @param {string|Date} date + * @returns {string} Formatted date, or '' if invalid + */ +function formatDate(date) { + if(!date) + return date || ''; + try { + var d = Date.parse(date); + if(isNaN(d)) + return date || ''; + return new Date(d).toLocaleDateString(); + } catch(e) { + return date || ''; + } +} + +/** + * Format a date & time into local format. + * @param {string} date + * @returns {string} Formatted date, or '' if invalid + */ +function formatDateTime(date) { + return formatDate(date); +} + +/** + * Format a decimal number to 2 places. + * @param number + * @returns {string} + */ +function formatNumber(number) { + return number == null || number === '' ? '' : parseFloat(number).toFixed(2); +} + +/** + * Format a decimal number to 2 places with commas. + * @param number + * @returns {string} + */ +function formatNumberWithCommas(number) { + if( number == null || number === '') + return ''; + number = parseFloat(number).toLocaleString(); + var p = number.indexOf(decPoint); + if(p == -1) + return number + '.00'; + return (number + '00').substr(0, p + 3); +} + +/** + * Format a decimal number to 2 places with commas, and brackets if negative. + * @param number + * @returns {string} + */ +function formatNumberWithBrackets(number) { + if( number == null || number === '') + return ''; + number = formatNumberWithCommas(number); + if(number[0] == '-') + number = '(' + number.substr(1) + ')'; + else + number += "\u00a0"; + return number; +} + +/** + * Format a double number with up to 4 places (no trailing zeroes after decimal point). + * @param {number|string} number + * @returns {string} + */ +function formatDouble(number) { + if( number != null && number !== '') { + number = parseFloat(number).toFixed(4); + if (number.indexOf('.') >= 0) { + var zeroes = /\.?0+$/.exec(number); + if(zeroes) { + number = number.substr(0, number.length - zeroes[0].length) + + '' + zeroes[0] + ''; + } + } + return number; + } + return ''; +} + +/** + * Format an integer + * @param number + * @returns {*} + */ +function formatInteger(number) { + return number == null || number === '' ? '' : parseInt(number); +} + +/** + * Split a fractional number into 2 parts (e.g. Hours and Minutes) + * @param {number} n number + * @param {number} m Number of second part items in 1 first part item (e.g. Mins in an Hour) + * @returns {*[]} The whole part, and the fractional part multiplied by m + */ +function splitNumber(n, m) { + var sign = n < 0 ? -1 : 1; + n = Math.abs(n); + var w = Math.floor(n); + return [sign * w, (n - w) * m]; +} + +/** + * Add leading zeroes so result is 2 characters long + * @param n + * @returns {string} + */ +function fillNumber(n) { + return ('00' + n).slice(-2); +} + +/** + * Convert a fractional number to one of our supported units for display + * @param data The number + * @param unit + * @returns {string} + */ +function toUnit(data, unit) { + if(data) { + switch (unit) { + case 1: // D:H:M + var d = splitNumber(data, 8); + var m = splitNumber(d[1], 60); + data = d[0] + ':' + m[0] + ':' + fillNumber(m[1]); + break; + case 2: // H:M + d = splitNumber(data, 60); + data = d[0] + ':' + fillNumber(d[1]); + break; + case 3: + data = parseFloat(data).toFixed(0); + break; + case 4: + data = parseFloat(data).toFixed(1); + break; + case 5: + data = parseFloat(data).toFixed(2); + break; + case 6: + data = parseFloat(data).toFixed(3); + break; + case 7: + data = parseFloat(data).toFixed(4); + break; + default: + data = parseFloat(data).toFixed(4).replace(/\.?0+$/, ''); + break; + } + } + return data; +} + +/** + * Convert an input unit into a fractional number + * @param data + * @param unit + * @returns {number} + */ +function fromUnit(data, unit) { + if(data) { + switch (unit) { + case 0: + data = parseDouble(data); + break; + case 1: // D:H:M + var parts = data.split(':'); + switch(parts.length) { + case 1: + data = parseDouble(parts[0]); + break; + case 2: + data = parseDouble(parts[0]) + parseDouble(parts[1]) / 8; + break; + case 3: + data = parseDouble(parts[0]) + parseDouble(parts[1]) / 8 + parseDouble(parts[2]) / 480; + break; + default: + throw data + ' is not in the format D:H:M'; + } + break; + case 2: // H:M + parts = data.split(':'); + switch(parts.length) { + case 1: + data = parseDouble(parts[0]); + break; + case 2: + data = parseDouble(parts[0]) + parseDouble(parts[1]) / 60; + break; + case 3: + data = parseFloat(parts[0]) * 8 + parseDouble(parts[1]) + parseDouble(parts[2]) / 60; + break; + default: + throw data + ' is not in the format (D:)H:M'; + } + break; + case 3: + data = parseInteger(data); + break; + } + } + return data; +} + +//noinspection JSUnusedLocalSymbols,JSUnusedLocalSymbols +/** + * Form field types + * Each type has the following optional members (which may be overridden in the field definition): + * render(data, type, row, meta): Renders - see DataTable documentation + * data: The data for the field + * type: The render type + * row: The data for the whole row + * meta: Information about the field + * draw(data, rowno, row): Renders for display + * data: The data for the field + * rowno: The row number + * row: The data for the whole row + * defaultContent(index, col): Html to display if there is no data yet + * update(cell, data, rowno, row): Update the table cell with the current value of data + * cell: Table cell + * data: The data for the field + * rowno: The row number + * row: The data for the whole row + * inputValue(field, row): Extract the input value from what the user typed in the field + * field: JQuery selector of the input field + * row: The data for the whole row + * sClass: The css class + * name: The field name + * heading: The field heading + * selectOptions: Array of options for selects + * + * If any of the above are not supplied, suitable defaults are created + */ +var Type = { + // Display only fields + string: { + }, + date: { + render: function(data, type, row, meta) { + switch(type) { + case 'display': + case 'filter': + return colRender(data, type, row, meta); + default: + return data ? data.substr(0, 10) : data; + } + }, + draw: function(data, rowno, row) { + return formatDate(data); + } + }, + dateTime: { + render: function(data, type, row, meta) { + switch(type) { + case 'display': + case 'filter': + return colRender(data, type, row, meta); + default: + return data; + } + }, + draw: function(data, rowno, row) { + return formatDateTime(data); + } + }, + decimal: { + render: { + display: formatNumberWithCommas, + filter: formatNumber + }, + draw: formatNumberWithCommas, + sClass: 'n' + }, + bracket: { + render: { + display: formatNumberWithBrackets, + filter: formatNumber + }, + draw: formatNumberWithBrackets, + sClass: 'n' + }, + double: { + render: { + display: formatDouble, + filter: formatDouble + }, + draw: formatDouble, + sClass: 'n' + }, + amount: { + render: function(data, type, row, meta) { + switch(type) { + case 'display': + case 'filter': + return colRender(data, type, row, meta); + default: + return data; + } + }, + draw: function(data, rowno, row) { + return formatNumberWithCommas(Math.abs(data)); + }, + sClass: 'n' + }, + credit: { + // Displays abs value only if negative + name: 'Credit', + heading: 'Credit', + render: function(data, type, row, meta) { + switch(type) { + case 'display': + case 'filter': + return colRender(data, type, row, meta); + default: + return data; + } + }, + draw: function(data, rowno, row) { + if(row["Credit"] !== undefined) + data = -row["Credit"]; + return data < 0 ? formatNumberWithCommas(-data) : ''; + }, + sClass: 'n' + }, + debit: { + // Displays value only if positive (or 0) + name: 'Debit', + heading: 'Debit', + render: function(data, type, row, meta) { + switch(type) { + case 'display': + case 'filter': + return colRender(data, type, row, meta); + default: + return data; + } + }, + draw: function(data, rowno, row) { + if(row["Debit"] !== undefined) + data = row["Debit"]; + return data >= 0 ? formatNumberWithCommas(data) : ''; + }, + sClass: 'n' + }, + int: { + render: { + display: formatInteger, + filter: formatInteger + }, + draw: formatInteger, + sClass: 'n' + }, + email: { + render: function(data, type, row, meta) { + switch(type) { + case 'display': + return colRender(data, type, row, meta); + default: + return data; + } + }, + draw: function(data, rowno, row) { + return data ? '' + data + '' : ''; + } + }, + checkbox: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + return ''; + } + }, + select: { + // Displays appropriate text from selectOptions according to value + draw: function(data, rowno, row) { + if(this.selectOptions) { + var opt = _.find(this.selectOptions, function(o) { return o.id == data; }); + if(opt) + data = opt.value; + } + return data; + } + }, + autoComplete: { + // Auto complete input field + defaultContent: function(index, col, row) { + if(col.confirmAdd) { + // Prompt user if value doesn't already exist in selectOptions + //noinspection JSUnusedLocalSymbols + col.change = function(newValue, rowData, col, input) { + var item = _.find(col.selectOptions, function (v) { + return v.value == newValue + }); + if (item === undefined) { + if (confirm(col.heading + ' ' + newValue + ' not found - add')) { + item = { + id: 0, + value: newValue + }; + col.selectOptions.push(item); + } else { + return false; + } + } + }; + } + return ''; + }, + draw: function(data, rowno, row) { + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.val(data); + } else { + cell.html(''); + i = cell.find('input'); + } + if(i.hasClass('ui-autocomplete-input')) + return; + var self = this; + //noinspection JSUnusedLocalSymbols + var options = { + source: function(req, resp) { + var re = $.ui.autocomplete.escapeRegex(req.term); + var matcher = new RegExp( "^" + re, "i" ); + resp(_.filter(self.selectOptions, function(o) { + return !o.hide && matcher.test(o.value); + })); + }, + change: function(e) { + $(this).trigger('change'); + } + }; + if($.isArray(this.selectOptions) && this.selectOptions.length > 0 && this.selectOptions[0].category != null) { + i.catcomplete(options); + } else { + i.autocomplete(options); + } + } + }, + textInput: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + if(data == null) + data = ""; + return ''; + }, + update: function(cell, data, rowno, row) { + colUpdate('input', cell, data, rowno, this, row); + }, + size: 45 + }, + docIdInput: { + // Document number - also add a "Next" button to set value to + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + if(data == null) + data = ""; + var result = ''; + if(row.idDocument !== undefined && !row.idDocument) + result += ''; + return result; + }, + update: function(cell, data, rowno, row) { + colUpdate('input', cell, data, rowno, this, row); + }, + size: 45 + }, + passwordInput: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + if(data == null) + data = ""; + return ''; + }, + update: function(cell, data, rowno, row) { + colUpdate('input', cell, data, rowno, this, row); + }, + size: 45 + }, + textAreaInput: { + defaultContent: function(index, col) { + var rows = col.rows || 6; + var cols = col.cols || 50; + return ''; + }, + draw: function(data, rowno, row) { + if(data == null) + data = ""; + var rows = this.rows || 5; + var cols = this.cols || 50; + return ''; + }, + update: function(cell, data, rowno, row) { + colUpdate('textarea', cell, data, rowno, this, row); + } + }, + dateInput: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + data = data ? data.substr(0, 10) : ''; + return ''; + }, + update: function(cell, data, rowno, row) { + data = data ? data.substr(0, 10) : ''; + colUpdate('input', cell, data, rowno, this, row); + } + }, + decimalInput: { + // 2 dec places + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.val(formatNumber(data)); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + return parseNumber($(field).val()); + }, + sClass: 'ni' + }, + doubleInput: { + // Up to 4 dec places + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.val(toUnit(data, row.Unit)); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + return fromUnit($(field).val(), row.Unit); + }, + attributes: 'size="7"', + sClass: 'ni' + }, + creditInput: { + name: 'Credit', + heading: 'Credit', + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + data = data < 0 ? formatNumber(-data) : ''; + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.val(data < 0 ? formatNumber(-data) : ''); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + return parseNumber($(field).val()) * -1; + }, + sClass: 'ni' + }, + debitInput: { + name: 'Debit', + heading: 'Debit', + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + data = data >= 0 ? formatNumber(data) : ''; + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.val(data >= 0 ? formatNumber(data) : ''); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + return parseNumber($(field).val()); + }, + sClass: 'ni' + }, + intInput: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.val(formatInteger(data)); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + return parseInteger($(field).val()); + }, + sClass: 'ni' + }, + checkboxInput: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.prop('checked', data ? true : false); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + return $(field).prop('checked') ? 1 : 0; + } + }, + imageInput: { + // Image file, with auto upload + defaultContent: function(index, col) { + return '
    '; + }, + draw: function(data, rowno, row) { + if(data == null) + data = ''; + return '
    '; + }, + update: function(cell, data, rowno, row) { + if(data == null) + data = ''; + var i = cell.find('img'); + if(i.length && i.attr('id')) { + i.prop('src', data); + } else { + cell.html(this.draw(data, rowno, row)); + } + } + }, + radioInput: { + // Radio buttons from select options + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + var select = ''; + var self = this; + if(this.selectOptions) { + _.each(this.selectOptions, function(o) { + select += ' '; + }); + } + return select; + }, + update: function(cell, data, rowno, row) { + if(cell.find('input#r' + rowno + 'c' + this.name).length == 0) { + cell.html(this.draw(data, rowno, row)); + } else { + var i = cell.find('input[type=radio][value=' + data + ']'); + if (i.length) + i.prop('checked', true); + } + }, + inputValue: function(field, row) { + return field.value; + } + + }, + textAreaField: { + defaultContent: function(index, col) { + var rows = col.rows || 6; + var cols = col.cols || 50; + return ''; + }, + draw: function(data, rowno, row) { + if(data == null) + data = ""; + var rows = this.rows || 5; + var cols = this.cols || 50; + return ''; + }, + update: function(cell, data, rowno, row) { + colUpdate('textarea', cell, data, rowno, this, row); + } + }, + decimalField: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + return ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('input'); + if(i.length && i.attr('id')) { + i.val(formatNumber(data)); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + sClass: 'ni' + }, + doubleField: { + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + return ''; + }, + update: function(cell, data, rowno, row) { + colUpdate('input', cell, data, rowno, this, row); + }, + sClass: 'ni' + }, + selectInput: { + defaultContent: function(index, col) { + return ''; + if(this.selectOptions) { + var jselect = $(select); + addOptionsToSelect(jselect, this.selectOptions, data, this); + select = $('
    ').append(jselect).html(); + select = select.replace(' value="' + data + '"', ' value="' + data + '" selected'); + } + return select; + }, + update: function(cell, data, rowno, row) { + colUpdate('select', cell, data, rowno, this, row); + } + }, + selectFilter: { + // Report filter + defaultContent: function(index, col) { + return ''; + if(this.selectOptions) { + var jselect = $(select); + addOptionsToSelect(jselect, this.selectOptions, data, this); + select = $('
    ').append(jselect).html(); + select = select.replace(' value="' + data + '"', ' value="' + data + '" selected'); + } + return select; + }, + update: function(cell, data, rowno, row) { + colUpdate('select', cell, data, rowno, this, row); + } + }, + dateFilter: { + // Report date filter + defaultContent: function(index, col) { + return ' - '; + }, + draw: function(data, rowno, row) { + var range = data.range || 4; + var disabled = range == 12 ? '' : 'disabled '; + var start = data.start ? data.start.substr(0, 10) : ''; + var end = data.end ? data.end.substr(0, 10) : ''; + var select = ' - '; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('select'); + var range = data.range || 4; + var start = data.start ? data.start.substr(0, 10) : ''; + var end = data.end ? data.end.substr(0, 10) : ''; + if(i.length && i.attr('id')) { + i.val(range); + i = cell.find('input'); + i.prop('disabled', range != 12); + $(i[0]).val(start); + $(i[1]).val(end); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + var cell = $(field).closest('td'); + var range = cell.find('select').val(); + cell.find('input').prop('disabled', range != 12); + return { + range: range, + start: cell.find('input:first').val(), + end: cell.find('input:last').val() + }; + } + }, + multiSelectFilter: { + // Report multi select filter + defaultContent: function(index, col) { + return ''; + if(this.selectOptions) { + var jselect = $(select); + addOptionsToSelect(jselect, this.selectOptions, data, this); + select = $('
    ').append(jselect).html(); + _.each(data, function(d) { + select = select.replace(' value="' + d + '"', ' value="' + d + '" selected'); + }); + } + return select; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('select'); + if(i.length && i.attr('id')) { + i.val(data); + } else { + cell.html(this.draw(data, rowno, row)); + i = cell.find('select'); + } + if(i.css('display') != 'none') + i.multiselect({ + selectedList: 2, + uncheckAllText: 'No filter', + noneSelectedText: 'No filter' + }); + } + }, + decimalFilter: { + // Report decimal filter + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + var comparison = data.comparison || 0; + var disabled = comparison > 2 ? '' : 'disabled '; + var value = data.value || 0; + var select = ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('select'); + var comparison = data.comparison || 0; + var value = data.value || 0; + if(i.length && i.attr('id')) { + i.val(comparison); + i = cell.find('input'); + i.prop('disabled', comparison <= 2); + i.val(value); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + var cell = $(field).closest('td'); + var comparison = cell.find('select').val(); + cell.find('input').prop('disabled', comparison <= 2); + return { + comparison: comparison, + value: parseNumber(cell.find('input').val()) + }; + } + }, + doubleFilter: { + // Report double (up tp 4 places) filter + defaultContent: function(index, col) { + return ''; + }, + draw: function(data, rowno, row) { + var comparison = data.comparison || 0; + var disabled = comparison > 2 ? '' : 'disabled '; + var value = data.value || 0; + var select = ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('select'); + var comparison = data.comparison || 0; + var value = data.value || 0; + if(i.length && i.attr('id')) { + i.val(comparison); + i = cell.find('input'); + i.prop('disabled', comparison <= 2); + i.val(value); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + var cell = $(field).closest('td'); + var comparison = cell.find('select').val(); + cell.find('input').prop('disabled', comparison <= 2); + return { + comparison: comparison, + value: parseDouble(cell.find('input').val()) + }; + } + }, + stringFilter: { + // Report string filter + defaultContent: function(index, col) { + return '' + + ''; + var jselect = $(select); + addOptionsToSelect(jselect, stringSelectOptions, comparison); + select = $('
    ').append(jselect).html(); + select = select.replace(' value="' + comparison + '"', ' value="' + comparison + '" selected'); + return select + ''; + }, + update: function(cell, data, rowno, row) { + var i = cell.find('select'); + var comparison = data.comparison || 0; + var value = data.value || ''; + if(i.length && i.attr('id')) { + i.val(comparison); + i = cell.find('input'); + i.prop('disabled', comparison <= 2); + i.val(value); + } else { + cell.html(this.draw(data, rowno, row)); + } + }, + inputValue: function(field, row) { + var cell = $(field).closest('td'); + var comparison = cell.find('select').val(); + cell.find('input').prop('disabled', comparison <= 2); + return { + comparison: comparison, + value: cell.find('input').val() + }; + } + } +}; + +/** + * Report date selection options + */ +var dateSelectOptions = [ + { id: 1, value: 'All' }, + { id: 2, value: 'Today' }, + { id: 3, value: 'This Week' }, + { id: 4, value: 'This Month' }, + { id: 5, value: 'This Quarter' }, + { id: 6, value: 'This Year' }, + { id: 7, value: 'Yesterday' }, + { id: 8, value: 'Last Week' }, + { id: 9, value: 'Last Month' }, + { id: 10, value: 'Last Quarter' }, + { id: 11, value: 'Last Year' }, + { id: 12, value: 'Custom' } + +]; + +/** + * Report decimal selection options + */ +var decimalSelectOptions = [ + { id: 0, value: 'All' }, + { id: 1, value: 'Zero' }, + { id: 2, value: 'Non-zero' }, + { id: 3, value: 'Less than or equal' }, + { id: 4, value: 'Greater than or equal' }, + { id: 5, value: 'Equal' }, + { id: 6, value: 'Not equal' } + +]; + +/** + * Report string selection options + */ +var stringSelectOptions = [ + { id: 0, value: 'All' }, + { id: 1, value: 'Empty' }, + { id: 2, value: 'Non-empty' }, + { id: 3, value: 'Equal' }, + { id: 4, value: 'Contains' }, + { id: 5, value: 'Starts with' }, + { id: 6, value: 'Ends with' } +]; + +/** + * Memorised task repeat selection options + */ +var repeatSelectOptions = [ + { id: 0, value: '' }, + { id: 1, value: 'Daily' }, + { id: 2, value: 'Weekly' }, + { id: 3, value: 'Monthly' }, + { id: 4, value: 'Quarterly' }, + { id: 5, value: 'Yearly' } +]; + +/** + * Units + */ +var unitOptions = [ + { id: 0, value: 'decimal', unit: '' }, + { id: 1, value: 'days', unit: 'D:H:M' }, + { id: 2, value: 'hours', unit: 'H:M' }, + { id: 3, value: 'units', unit: '' }, + { id: 4, value: '1 dp', unit: '' }, + { id: 5, value: '2 dp', unit: '' }, + { id: 6, value: '3 dp', unit: '' }, + { id: 7, value: '4 dp', unit: '' } +]; + +/** + * Descriptions of units to show in unit column + */ +var unitDisplay = [ + { id: 0, value: '' }, + { id: 1, value: 'D:H:M' }, + { id: 2, value: 'H:M' }, + { id: 3, value: '' } +]; + +var DataTable; +/** + * Make a DataTable + * Column options are: + * {string} [prefix]dataItemName[/heading] (prefix:#=decimal, /=date, @=email) + * or + * {*} [type] Type.* - sets defaults for column options + * {string} data item name + * {string} [heading] + * {boolean|*} nonZero true to suppress zero items, with button to reveal, false opposite, or: + * {boolean} [hide] true to suppress zero items, with button to reveal, false opposite + * {string} [heading] to use in button text (col.heading) + * {string} [zeroText] prompt for button (Show all ) + * {string} [nonZeroText] prompt for button (Only non-zero ) + * @param {string} selector + * @param options + * @param {string} [options.table] Name of SQL table + * @param {string} [options.idName] Name of id field in table (id) + * @param {string|function} [options.select] Url to go to or function to call when a row is clicked + * @param {string|*} [options.ajax] Ajax settings, or string url (current url + 'Listing') + * @param {number?} [options.iDisplayLength] Number of items to display per screen + * @param {Array} [options.order] Initial sort order (see jquery.datatables) + * @param {boolean?} [options.stateSave] Whether to save state + * @param {function} [options.stateSaveCallback] Callback to save state + * @param {function} [options.stateLoadCallback] Callback to load saved state + * @param {*} [options.data] Existing data to display + * @param {Array} options.columns + * @param {function} [options.validate] Callback to validate data + * @returns {*} + */ +function makeDataTable(selector, options) { + var tableName = myOption('table', options); + var idName = myOption('id', options, 'id' + tableName); + var selectUrl = myOption('select', options); + // Show All options + var nzColumns = []; + var dtParam = getParameter('dt'); + var nzList = dtParam === null || dtParam === '' ? [] : dtParam.split(','); + // Default number of items to display depends on screen size + if(options.iDisplayLength === undefined && $(window).height() >= 1200) + options.iDisplayLength = 25; + if (typeof(selectUrl) == 'string') { + // Turn into a function that goes to url, adding id of current row as parameter + var s = selectUrl; + selectUrl = function (row) { + goto(s + '?id=' + row[idName]); + } + } + // If no data or data url supplied, use an Ajax call to this method + "Listing" + if (options.data === undefined) + _setAjaxObject(options, 'Listing', ''); + $(selector).addClass('form'); + // Make sure there is a table heading + var heading = $(selector).find('thead tr'); + if (heading.length == 0) heading = $('').appendTo($('').appendTo($(selector))); + var columns = {}; + // Process the columns + _.each(options.columns, function (col, index) { + // Set up the column - add any missing functions, etc. + options.columns[index] = col = _setColObject(col, tableName, index); + var title = myOption('heading', col); + $('').appendTo(heading).text(title).addClass(col.sClass).attr('title', col.hint); + // Add to columns hash by name + columns[col.name] = col; + // "Show All" option? + var nz = myOption('nonZero', col); + if (nz != undefined) { + if (typeof(nz) == 'boolean') nz = {hide: nz}; + if (nz.hide === undefined) nz.hide = true; + if (nzList.length) + nz.hide = nzList.shift() == 1; + nz.col = col; + if (nz.heading === undefined) nz.heading = title; + nzColumns.push(nz); + } + col.index = index; + }); + if (options.order == null) + options.order = []; + if (options.stateSave === undefined) { + // By default, save and restore table UI state + options.stateSave = true; + options.stateLoadCallback = function (settings) { + try { + if (dtParam !== null) + return JSON.parse(sessionStorage.getItem( + 'DataTables_' + settings.sInstance + '_' + location.pathname + '_' + getParameter('id') + )); + } catch (e) { + } + }; + options.stateSaveCallback = function (settings, data) { + try { + sessionStorage.setItem( + 'DataTables_' + settings.sInstance + '_' + location.pathname + '_' + getParameter('id'), + JSON.stringify(data) + ); + } catch (e) { + } + }; + } + if (typeof(selectUrl) == 'function') + $(selector).addClass('noselect'); + var table = $(selector).dataTable(options); + + // Attach mouse handlers to each row + if (typeof(selectUrl) == 'function') { + selectClick(selector, function () { + return selectUrl.call(this, table.rowData($(this))) + }); + } else { + selectClick(selector, null); + } + // "Show All" functionality + _.each(nzColumns, function(nz) { + var zText = nz.zeroText || ('Show all ' + nz.heading); + var nzText = nz.nonZeroText || ('Only non-zero ' + nz.heading); + //noinspection JSUnusedLocalSymbols + $('').insertBefore($(selector)) + .html(nz.hide ? nzText : zText) + .click(function(e) { + nz.hide = !nz.hide; + $(this).attr('data-nz', nz.hide); + $(this).html(nz.hide ? nzText : zText); + table.api().draw(); + }); + $.fn.dataTable.ext.search.push( + function(settings, dataArray, dataIndex, data) { + return !nz.hide || !/^([0\.]*|true)$/.test(data[nz.col.data]); + } + ); + if(nz.hide) + table.api().draw(false); + }); + // Attach event handler to input fields + $('body').off('change', selector + ' :input'); + $('body').on('change', selector + ' :input', function() { + var col = table.fields[$(this).attr('data-col')]; + if(col) { + var row = table.row(this); + var data = row.data(); + var val; + try { + //noinspection JSCheckFunctionSignatures + val = col.inputValue(this, row); + } catch(e) { + message(col.heading + ':' + e); + $(this).focus(); + return; + } + setTimeout(function() { + if ($(selector).triggerHandler('changed.field', [val, data, col, this]) != false) { + data[col.data] = val; + } + }, 10); + } + }); + /** + * Return the tr row of item clicked on + * @param item + * @returns {*} + */ + table.row = function(item) { + item = $(item); + if(item.attr['tagName'] != 'tr') item = item.closest('tr'); + return table.api().row(item); + }; + /** + * Refresh the row containing item without losing the focus + * @param item + */ + table.refreshRow = function(item) { + var focus = $(':focus'); + var col = focus.closest('td').index(); + var row = focus.closest('tr').index(); + var refocus = focus.closest('table')[0] == table[0]; + table.row(item).invalidate().draw(false); + if(refocus) + table.find('tbody tr:eq(' + row + ') td:eq(' + col + ') :input').focus(); + }; + /** + * Refresh the whole table without losing the focus + */ + table.refresh = function() { + var focus = $(':focus'); + var col = focus.closest('td').index(); + var row = focus.closest('tr').index(); + var refocus = focus.closest('table')[0] == table[0]; + table.api().draw(false); + if(refocus) + table.find('tbody tr:eq(' + row + ') td:eq(' + col + ') :input').focus(); + }; + /** + * Return the data for the row containing r + * @param r + * @returns {*} + */ + table.rowData = function(r) { + return table.row(r).data(); + }; + /** + * When data has arrived, update the table + * @param data + */ + table.dataReady = function(data) { + table.api().clear(); + table.api().rows.add(data); + table.api().draw(); + }; + table.fields = columns; + DataTable = table.api(); + return table; +} + +/** + * Make a form to edit a single record and post it back + * Column options are: + * {string} [prefix]dataItemName[/heading] (prefix:#=decimal, /=date, @=email) + * or + * {*} [type] Type.* - sets defaults for column options + * {string} data item name + * {string} [heading] + * {boolean|*} nonZero true to suppress zero items, with button to reveal, false opposite, or: + * {boolean} [hide] true to suppress zero items, with button to reveal, false opposite + * {string} [heading] to use in button text (col.heading) + * {string} [zeroText] prompt for button (Show all ) + * {string} [nonZeroText] prompt for button (Only non-zero ) + * @param {string} selector + * @param options + * @param {string} [options.table] Name of SQL table + * @param {string} [options.idName] Name of id field in table (id
    ) + * @param {string|function} [options.select] Url to go to or function to call when a row is clicked + * @param {string|*} [options.ajax] Ajax settings, or string url (current url + 'Listing') + * @param {boolean} [options.dialog] Show form as a dialog when Edit button is pushed + * @param {string} [options.submitText} Text to use for Save buttons (default "Save") + * @param {boolean} [options.saveAndClose} Include Save and Close button (default true) + * @param {boolean} [options.saveAndNew} Include Save and New button (default false) + * @param {*} [options.data] Existing data to display + * @param {Array} options.columns + * @param {function} [options.validate] Callback to validate data + * @returns {*} + */ +function makeForm(selector, options) { + var tableName = myOption('table', options); + var canDelete = myOption('canDelete', options); + var submitUrl = myOption('submit', options); + var deleteButton; + if(submitUrl === undefined) { + submitUrl = defaultUrl('Post'); + } + if(typeof(submitUrl) == 'string') { + // Turn url into a function that validates and posts + var submitHref = submitUrl; + /** + * Submit method attached to button + * @param button The button pushed + */ + submitUrl = function(button) { + var hdg = null; + try { + // Check each input value is valid + _.each(options.columns, function (col) { + if(col.inputValue) { + hdg = col.heading; + col.inputValue(col.cell.find('#r0c' + col.name), result.data); + } + }); + } catch(e) { + message(hdg + ':' + e); + return; + } + if(options.validate) { + var msg = options.validate(); + message(msg); + if(msg) return; + } + postJson(submitHref, result.data, function(d) { + $('button#Back').text('Back'); + if($(button).hasClass('goback')) { + goback(); // Save and Close + } else if($(button).hasClass('new')) { + window.location = urlParameter('id', 0); // Save and New + } else if(tableName && d.id) { + window.location = urlParameter('id', d.id); // Redisplay saved record + } + }); + } + } + var deleteUrl = canDelete && !matchingStatement() ? myOption('delete', options) : null; + if(deleteUrl === undefined) { + deleteUrl = defaultUrl('Delete'); + } + if(typeof(deleteUrl) == 'string') { + var deleteHref = deleteUrl; + //noinspection JSUnusedLocalSymbols + deleteUrl = function(button) { + postJson(deleteHref, result.data, goback); + } + } + $(selector).addClass('form'); + _setAjaxObject(options, 'Data', ''); + var row; + var columns = {}; + _.each(options.columns, function(col, index) { + options.columns[index] = col = _setColObject(col, tableName, index); + if(!row || !col.sameRow) + row = $('').appendTo($(selector)); + $('').appendTo(row).text(col.heading).attr('title', col.hint); + col.cell = $('').appendTo(row).html(col.defaultContent); + if(col.colspan) + col.cell.attr('colspan', col.colspan); + columns[col.name] = col; + col.index = index; + }); + // Attach event handler to input fields + $('body').off('change', selector + ' :input'); + $('body').on('change', selector + ' :input', function(/** this: jElement */) { + $('button#Back').text('Cancel'); + var col = result.fields[$(this).attr('data-col')]; + if(col) { + var val; + try { + //noinspection JSCheckFunctionSignatures + val = col.inputValue(this, result.data); + } catch(e) { + message(col.heading + ':' + e); + $(this).focus(); + return; + } + if(col.change && col.change(val, result.data, col, this) == false) { + $(this).val(result.data[col.data]) + .focus(); + return; + } + if($(selector).triggerHandler('changed.field', [val, result.data, col, this]) != false) { + if(this.type == 'file') { + var img = $(this).prev('img'); + var submitHref = defaultUrl('Upload'); + var d = new FormData(); + for(var f = 0; f < this.files.length; f++) + d.append('file' + (f || ''), this.files[f]); + d.append('json', JSON.stringify(result.data)); + postFormData(submitHref, d, function(d) { + if(tableName && d.id) + window.location = urlParameter('id', d.id); + }); + } else { + result.data[col.data] = val; + _.each(options.columns, function (c) { + if (c.data == col.data) { + c.update(c.cell, val, 0, result.data); + } + }); + } + } + } + }); + var result = $(selector); + + /** + * Redraw form fields + */ + function draw() { + //noinspection JSUnusedLocalSymbols + _.each(options.columns, function (col, index) { + var colData = result.data[col.data]; + col.update(col.cell, colData, 0, result.data); + }); + } + var drawn = false; + + /** + * Draw form when data arrives + * @param d + */ + function dataReady(d) { + result.data = d; + if(deleteButton && !d['id' + tableName]) + deleteButton.remove(); + draw(); + // Only do this bit once + if(drawn) + return; + drawn = true; + if(submitUrl) { + if(options.dialog) { + // Wrap form in a dialog, called by Edit button + result.wrap('
    '); + result.parent().dialog({ + autoOpen: false, + modal: true, + height: Math.min(result.height() + 200, $(window).height() * 0.9), + width: Math.min(result.width() + 20, $(window).width()), + buttons: { + Ok: { + id: 'Ok', + text: 'Ok', + click: function() { + submitUrl(this); + $(this).dialog("close"); + } + }, + Cancel: { + id: 'Cancel', + text: 'Cancel', + click: function () { + $(this).dialog("close"); + } + } + } + }); + actionButton('Edit') + .click(function (e) { + result.parent().dialog('open'); + e.preventDefault(); + }); + } else { + // Add Buttons + actionButton(options.submitText || 'Save') + .click(function (e) { + submitUrl(this); + e.preventDefault(); + }); + if(!matchingStatement()) { + if (options.saveAndClose !== false) + actionButton((options.submitText || 'Save') + ' and Close') + .addClass('goback') + .click(function (e) { + submitUrl(this); + e.preventDefault(); + }); + if (options.saveAndNew) + actionButton((options.submitText || 'Save') + ' and New') + .addClass('new') + .click(function (e) { + submitUrl(this); + e.preventDefault(); + }); + } + actionButton('Reset') + .click(function () { + window.location.reload(); + }); + } + } + if(deleteUrl) { + deleteButton = actionButton('Delete') + .click(function (e) { + if(confirm("Are you sure you want to delete this record")) + deleteUrl(this); + e.preventDefault(); + }); + } + } + result.fields = columns; + result.settings = options; + result.dataReady = dataReady; + result.draw = draw; + if(options.data) + dataReady(options.data); + else if(options.ajax) { + get(options.ajax.url, null, dataReady); + } + return result; +} + +/** + * Make a header and detail form + * @param headerSelector + * @param detailSelector + * @param options - has header and detail objects for the 2 parts of the form + */ +function makeHeaderDetailForm(headerSelector, detailSelector, options) { + var submitUrl = options.submit; + var tableName = options.header.table; + if(submitUrl === undefined) { + submitUrl = defaultUrl('Post'); + } + if(typeof(submitUrl) == 'string') { + var submitHref = submitUrl; + submitUrl = function(button) { + var hdg = null; + try { + // Validate everything + _.each(options.header.columns, function (col) { + if (col.inputValue) { + hdg = col.heading; + col.inputValue(result.header.find('#r0c' + col.name), result.header.data); + } + }); + _.each(options.detail.columns, function(col) { + if(col.inputValue) { + hdg = col.heading; + _.each(result.detail.data, function (row, index) { + col.inputValue(result.detail.find('#r' + index + 'c' + col.name), row); + }); + } + }); + } catch(e) { + message(hdg + ':' + e); + return; + } + if(options.header.validate) { + var msg = options.header.validate(); + message(msg); + if(msg) return; + } + if(options.validate) { + msg = options.validate(); + message(msg); + if(msg) return; + } + postJson(submitHref, { + header: result.data.header, + detail: result.data.detail + }, function(d) { + if($(button).hasClass('goback')) + goback(); + else if($(button).hasClass('new')) + window.location = urlParameter('id', 0); + else if(tableName && d.id) + window.location = urlParameter('id', d.id); + }); + } + } + if(options.header.submit === undefined) + options.header.submit = submitUrl; + if(options.header.ajax === undefined || options.detail.ajax === undefined) { + if (options.data) { + if(options.header.ajax === undefined) + options.header.ajax = null; + if(options.detail.ajax === undefined) + options.detail.ajax = null; + } else { + _setAjaxObject(options, 'Data', 'detail'); + if (options.ajax) { + get(options.ajax.url, null, dataReady); + } + } + } + function dataReady(d) { + result.data = d; + if (!options.header.data && !options.header.ajax) + result.header.dataReady(options.data.header); + if (!options.detail.data && !options.detail.ajax) + result.detail.dataReady(options.data.detail); + } + var result = { + header: makeForm(headerSelector, options.header), + detail: makeListForm(detailSelector, options.detail), + data: options.data + }; + result.detail.header = result.header; + if(!matchingStatement()) + nextPreviousButtons(result.data); + result.detail.bind('changed.field', function() { + $('button#Back').text('Cancel'); + }); + if(options.data) + dataReady(options.data); + return result; +} + +/** + * Make the detail part of a header detail form + * @param selector + * @param options + */ +function makeListForm(selector, options) { + var table = $(selector); + var tableName = myOption('table', options); + var idName = myOption('id', options, 'id' + tableName); + var submitUrl = myOption('submit', options); + var selectUrl = myOption('select', options); + if(selectUrl === undefined && submitUrl === undefined) { + submitUrl = defaultUrl('Post'); + } + if(typeof(submitUrl) == 'string') { + var s = submitUrl; + //noinspection JSUnusedAssignment,JSUnusedLocalSymbols + submitUrl = function(button) { + try { + var hdg; + _.each(options.columns, function(col) { + if(col.inputValue) { + hdg = col.heading; + _.each(table.data, function (row, index) { + col.inputValue(result.find('#r' + index + 'c' + col.name), row); + }); + } + }); + } catch(e) { + message(e); + return; + } + if(options.validate) { + var msg = options.validate(); + message(msg); + if(msg) return; + } + postJson(s, table.data); + } + } + if(typeof(selectUrl) == 'string') { + var sel = selectUrl; + selectUrl = function(row) { + goto(sel + '?id=' + row[idName]); + } + } + if(typeof(selectUrl) == 'function') { + selectClick(selector, function () { + return selectUrl.call(this, table.rowData($(this))) + }); + } else { + selectClick(selector, null); + } + $(selector).addClass('form'); + $(selector).addClass('listform'); + _setAjaxObject(options, 'Listing', ''); + var row = null; + var columns = {}; + var heading = table.find('thead'); + if(heading.length == 0) heading = $('').appendTo(table); + var body = table.find('tbody'); + if(body.length == 0) body = $('').appendTo(table); + var rowsPerRecord = 0; + var colCount = 0; + var c = 0; + var skip = 0; + _.each(options.columns, function(col, index) { + options.columns[index] = col = _setColObject(col, tableName, index); + if(!row || col.newRow) { + row = $('').appendTo(heading); + row.addClass("r" + ++rowsPerRecord); + c = 0; + } + c++; + if(skip) { + skip--; + } else { + var cell = $('').appendTo(row).text(col.heading).attr('title', col.hint); + if (col.colspan) { + cell.attr('colspan', col.colspan); + skip = col.colspan - 1; + } + if (col.sClass) + cell.attr('class', col.sClass); + } + columns[col.name] = col; + col.index = index; + colCount = Math.max(colCount, c); + }); + if(options.deleteRows && rowsPerRecord == 1) + $('').appendTo(row); + $('body').off('change', selector + ' :input'); + $('body').on('change', selector + ' :input', function() { + var col = table.fields[$(this).attr('data-col')]; + if(col) { + var rowIndex = table.rowIndex(this); + var val; + try { + //noinspection JSCheckFunctionSignatures + val = col.inputValue(this, table.data[rowIndex]); + } catch(e) { + message(col.heading + ':' + e); + $(this).focus(); + return; + } + if(table.triggerHandler('changed.field', [val, table.data[rowIndex], col, this]) != false) { + if(this.type == 'file') { + var img = $(this).prev('img'); + var submitHref = defaultUrl('Upload'); + var d = new FormData(); + for(var f = 0; f < this.files.length; f++) + d.append('file' + (f || ''), this.files[f]); + if(table.header) + d.append('header', JSON.stringify(table.header.data)); + d.append('detail', JSON.stringify(table.data[rowIndex])); + postFormData(submitHref, d, function(d) { + if(tableName && d.id) + window.location = urlParameter('id', d.id); + }); + } else { + table.data[rowIndex][col.data] = val; + row = body.find('tr:eq(' + (rowIndex * rowsPerRecord) + ')'); + var cell = row.find('td:first'); + } + _.each(options.columns, function(c) { + if(c.newRow) { + row = row.next('tr'); + cell = row.find('td:first'); + } + if(c.data == col.data) { + c.update(cell, val, rowIndex, table.data[rowIndex]); + } + cell = cell.next('td'); + }); + } + } + }); + /** + * Draw an individual row bu index + * @param rowIndex + */ + function drawRow(rowIndex) { + var row = null; + var cell = null; + var rowData = table.data[rowIndex]; + var rowno = 1; + function newRow(r) { + if(r.length == 0) { + r = $('').appendTo(body); + } + row = r; + if(rowData["@class"]) + row.addClass(rowData["@class"]); + row.addClass("r" + rowno++); + cell = row.find('td:first'); + } + newRow(body.find('tr:eq(' + (rowIndex * rowsPerRecord) + ')')); + _.each(options.columns, function (col) { + if(col.newRow) + newRow(row.next('tr')); + if(cell.length == 0) { + cell = $('').appendTo(row); + if(col.sClass) + cell.attr('class', col.sClass); + } + var data = rowData[col.data]; + col.update(cell, data, rowIndex, rowData); + cell = cell.next('td'); + }); + if(options.deleteRows && rowsPerRecord == 1) { + if(cell.length != 0) + cell.remove(); + cell = $('').appendTo(row); + $('').appendTo(cell).click(function() { + var row = $(this).closest('tr'); + var index = row.index(); + var callback; + if(typeof(options.deleteRows) == "function") + callback = options.deleteRows.call(row, table.data[index]); + if(callback != false) { + unsavedInput = true; + $('button#Back').text('Cancel'); + row.remove(); + table.data.splice(index, 1); + if(typeof(callback) == 'function') + callback(); + } + }); + } + } + + /** + * Draw the whole form + */ + function draw() { + for(var row = 0; row < table.data.length; row++) { + drawRow(row); + } + } + function dataReady(d) { + table.data = d; + body.find('tr').remove(); + draw(); + } + + /** + * Redraw + */ + function refresh() { + if(options.data) + dataReady(options.data); + else if(options.ajax) { + body.html(''); + get(options.ajax.url, null, dataReady); + } + } + refresh(); + table.fields = columns; + table.settings = options; + table.dataReady = dataReady; + table.draw = draw; + table.refresh = refresh; + /** + * Return the row index of item r + * @param r + * @returns {number} + */ + table.rowIndex = function(r) { + r = $(r); + if(r.attr('tagName') != 'TR') + r = r.closest('tr'); + return Math.floor(r.index() / rowsPerRecord); + }; + /** + * Return the row data for item r + * @param r + * @returns {*} + */ + table.rowData = function(r) { + return table.data[table.rowIndex(r)]; + }; + /** + * Draw the row + * @param {number|jElement} r rowIndex or item in a row + */ + table.drawRow = function(r) { + if(typeof(r) != 'number') + r = table.rowIndex(r); + draw(r); + }; + /** + * Add a new row + * @param row The data to add + */ + table.addRow = function(row) { + table.data.push(row); + drawRow(table.data.length - 1); + }; + /** + * The cell for a data item + * @param rowIndex + * @param col The col object + * @returns {*|{}} + */ + table.cellFor = function(rowIndex, col) { + var row = body.find('tr:eq(' + (rowIndex * rowsPerRecord) + ')'); + var cell = row.find('td:first'); + for(var c = 0; c < options.columns.length; c++) { + if(options.columns[c].newRow) { + row = row.next(); + cell = row.find('td:first'); + } + if(options.columns[c] == col) + return cell; + cell = cell.next(); + } + }; + return table; +} + +/** + * Extract a named option from opts, remove it, and return it (or defaultValue if not present) + * @param {string} name + * @param {*} opts + * @param {string} [defaultValue] + * @returns {*} + */ +function myOption(name, opts, defaultValue) { + var result = opts[name]; + if(result === undefined) { + result = defaultValue; + } else { + if(typeof(result) != 'function') result = _.clone(result); + delete opts[name]; + } + return result; +} + +/** + * Add next and previous buttons to a document display + * @param {number} record.next id of next record + * @param {number} record.previous id of previous record + */ +function nextPreviousButtons(record) { + if(record && record.previous != null) { + actionButton('Previous') + .click(function() { + window.location = urlParameter('id', record.previous); + }); + } + if(record && record.next != null) { + actionButton('Next') + .click(function() { + window.location = urlParameter('id', record.next); + }); + } +} + +/** + * Post data to url + * @param {string} url + * @param data + * @param {function} [success] + */ +function postJson(url, data, success) { + if(typeof(data) == 'function') { + success = data; + data = {}; + } + if(data == null) + data = {}; + postData(url, { json: JSON.stringify(data) }, false, success); +} + +/** + * Post form data containing uploaded file + * @param {string} url + * @param data + * @param {function} [success] + */ +function postFormData(url, data, success) { + postData(url, data, true, success, 60000); +} + +/** + * Post data + * @param {string} url + * @param data + * @param {boolean} asForm true to post as multiplart/form-data (uploaded file) + * @param {function} [success] + * @param {number} [timeout] in msec + */ +function postData(url, data, asForm, success, timeout) { + message(timeout > 10000 ? 'Please wait, uploading data...' : 'Please wait...'); + var ajax = { + url: url, + type: 'post', + data: data, + timeout: timeout || 10000, + xhrFields: { + withCredentials: true + } + }; + if(asForm) { + ajax.enctype = 'multipart/form-data'; + ajax.processData = false; + ajax.contentType = false; + } + $.ajax(ajax) + .done( + /** + * @param {string} [result.error] Error message + * @param {string} [result.message] Info message + * @param {string} [result.confirm] Confirmation question + * @param {string} [result.redirect] Where to go now + */ + function(result) { + if(result.error) + message(result.error); + else { + message(result.message); + if(result.confirm) { + // C# code wants a confirmation + if(confirm(result.confirm)) { + url += /\?/.test(url) ? '?' : '&'; + url += 'confirm'; + postData(url, data, asForm, success, timeout); + } + return; + } + unsavedInput = false; + if(success && !result.redirect) { + success(result); + return; + } + } + if(result.redirect) + window.location = result.redirect; + }) + .fail(function(jqXHR, textStatus, errorThrown) { + message(textStatus == errorThrown ? textStatus : textStatus + ' ' + errorThrown); + }); +} + +/** + * Round a number to 2 decimal places + * @param {number} v + * @returns {number} + */ +function round(v) { + return Math.round(100 * v) / 100; +} + +/** + * Add selected class to just this row + */ +function selectOn() { + $(this).siblings('tr').removeClass('selected'); + $(this).addClass('selected'); +} + +/** + * Remove selected class from this row + */ +function selectOff() { + $(this).removeClass('selected'); +} + +/** + * Add mouse handlers for table rows + * @param {string} selector table + * @param {function} selectFunction (returns false if row can't be selected) + */ +function selectClick(selector, selectFunction) { + $('body').off('click', selector + ' tbody td:not(:has(input))'); + if(!touchScreen) { + $('body').off('mouseenter', selector + ' tbody tr') + .off('mouseleave', selector + ' tbody tr'); + } + if(!selectFunction) + return; + var table = $(selector); + table.addClass('noselect'); + table.find('tbody').css('cursor', 'pointer'); + $('body').on('click', selector + ' tbody td:not(:has(input))', function(e) { + if(e.target.tagName == 'A') + return; + var row = $(this).closest('tr'); + // On touch screens, tap something once to select, twice to open it + // On ordinary screens, click once to open (mouseover selects) + var select = !touchScreen || row.hasClass('selected'); + selectOn.call(row); + if(select && selectFunction.call(this, e) == false) + selectOff.call(row); + e.preventDefault(); + e.stopPropagation(); + return false; + }); + if(!touchScreen) { + // Mouse over highlights row + $('body').on('mouseenter', selector + ' tbody tr', selectOn) + .on('mouseleave', selector + ' tbody tr', selectOff); + } +} + +/** + * Add defaultSuffix to the current url + * @param {string} defaultSuffix + * @returns {string} + */ +function defaultUrl(defaultSuffix) { + var url = window.location.pathname.replace(/\.html$/, ''); + if(url.substr(1).indexOf('/') < 0) + url += '/default'; + return url + defaultSuffix + ".html" + window.location.search +} + +/** + * Change (or add or delete) the value of a named parameter in a url + * @param {string} name of parameter + * @param {string|number} [value] new value (null or missing to delete) + * @param {string} [url] If missing, use current url with any message removed + * @returns {string} + */ +function urlParameter(name, value, url) { + if(url === undefined) + { //noinspection JSCheckFunctionSignatures + url = urlParameter('message', null, window.location.href); + } + var regex = new RegExp('([\?&])' + name + '(=[^\?&]*)?'); + if(value === null || value === undefined) { + var m = regex.exec(url); + if(m) + url = url.replace(regex, m[1] == '?' ? '?' : '').replace('?&', '?'); + } else if(regex.test(url)) + url = url.replace(regex, '$1' + name + '=' + value); + else + url += (url.indexOf('?') < 0 ? '?' : '&') + name + '=' + value; + return url; +} + +/** + * If options.data is not present, set options.ajax to retrieve the data + * @param options + * @param {string} defaultSuffix to add to current url if options.ajax is undefined + * @param {string} defaultDataSrc Element in returned data to use for form data (or '') + * @private + */ +function _setAjaxObject(options, defaultSuffix, defaultDataSrc) { + if(typeof(options.ajax) == 'string') { + options.ajax = { + url: options.ajax + } + } else if(options.ajax === undefined && !options.data) { + options.ajax = { + url: defaultUrl(defaultSuffix) + } + } + if(options.ajax && typeof(options.ajax) == 'object' && options.ajax.dataSrc === undefined) { + options.ajax.dataSrc = defaultDataSrc; + } +} + +//noinspection JSUnusedLocalSymbols +/** + * Default inputValue function for a column that doesn't have one + * @param {jElement} field + * @param {*} row + * @returns {string} + */ +function getValueFromField(field, row) { + return $(field).val(); +} + +/** + * Standard update function for a column + * @param {string} selector to find the input field in the cell + * @param {jElement} cell + * @param data + * @param {number} rowno + * @param col + * @param row + */ +function colUpdate(selector, cell, data, rowno, col, row) { + var i = cell.find(selector); + if(i.length && i.attr('id')) { + // Field exists + i.val(data); + } else { + // No field yet - draw one + cell.html(col.draw(data, rowno, row)); + } +} + +//noinspection JSUnusedLocalSymbols +/** + * Default render function for a column that doesn't have one + * @param data + * @param {string} type + * @param row + * @param {*} meta + * @param {number} meta.row + * @param {number} meta.col + * @param {Array} meta.settings.oInit.columns + * @returns {string} + */ +function colRender(data, type, row, meta) { + var col = meta.settings.oInit.columns[meta.col]; + return col.draw(data, meta.row, row); +} + +//noinspection JSUnusedLocalSymbols,JSUnusedLocalSymbols +/** + * Default draw function for a column that doesn't have one + * @param data + * @param {number} rowno + * @param row + * @returns {string} + */ +function colDraw(data, rowno, row) { + return data; +} + +/** + * Set up column option defaults + * @param col + * @param {string} tableName + * @param {int} index + * @returns {*} col + * @private + */ +function _setColObject(col, tableName, index) { + var type; + if(typeof(col) == 'string') { + // Shorthand - [#/@]name[/heading] + switch(col[0]) { + case '#': + type = 'decimal'; + col = col.substr(1); + break; + case '/': + type = 'date'; + col = col.substr(1); + break; + case '@': + type = 'email'; + col = col.substr(1); + break; + } + var split = col.split('/'); + col = { data: split[0] }; + if(split.length > 1) col.heading = split[1]; + } else { + type = myOption('type', col); + } + if (type) _.defaults(col, Type[type]); + if(col.attributes == null) + col.attributes = ''; + if(typeof(col.defaultContent) == "function") { + col.defaultContent = col.defaultContent(index, col); + } + if(!col.name) col.name = col.data.toString(); + if(col.heading === undefined) { + var title = col.name; + // Remove table name from front + if(tableName && title.indexOf(tableName) == 0 && title != tableName) + title = title.substr(tableName.length); + // Split "CamelCase" name into "Camel Case", and remove Id from end + title = title.replace(/Id$/, '').replace(/([A-Z])(?=[a-z0-9])/g, " $1"); + col.heading = title; + } + if(col.inputValue === undefined) + col.inputValue = getValueFromField; + if(col.render === undefined && col.draw) + col.render = colRender; // Render function for dataTable + if(col.draw === undefined) + col.draw = colDraw; + if(!col.update) + col.update = function(cell, data, rowno, row) { + cell.html(this.draw(data, rowno, row)); + }; + return col; +} + +/** + * Get a url + * @param {string} url + * @param data + * @param {function} success + * @param {function} failure + */ +function get(url, data, success, failure) { + $.ajax({ + url: url, + type: 'get', + data: data, + timeout: 10000, + xhrFields: { + withCredentials: true + } + }) + .done(success) + .fail(failure || function(jqXHR, textStatus, errorThrown) { + message(textStatus == errorThrown ? textStatus : textStatus + ' ' + errorThrown); + }); +} + +/** + * Populate a select with options + * @param {jElement} select + * @param {Array} data The options array + * @param {string} val current value of data item + * @param {*} [col] + * @param {boolean} [col.date] True if value to be formatted as date + * @param {string} [col.emptyValue] Value to use if val is null or missing + * @param {string} [col.emptyOption] Text to use if val does not match any option + */ +function addOptionsToSelect(select, data, val, col) { + var found; + var category; + var optgroup = select; + var multi = select.prop('multiple'); + var date = col && col.date; + if(val == null) + val = col && col.emptyValue != null ? col.emptyValue : ''; + _.each(data, + /** + * Populate a select with options + * @param {string} opt.value the text to display + * @param {string?} [opt.id] the value to return (value if not supplied) + * @param {boolean} [opt.hide] + * @param {string} [opt.category] For categorised options + * @param {string}[opt.class] css class + */ + function(opt) { + var id = opt.id; + if(id === undefined) + id = opt.value; + if(opt.hide && id != val) + return; + var option = $(''); + if(opt.id !== undefined) + option.attr('value', opt.id); + option.text(date ? formatDate(opt.value) : opt.value); + if(id == val) + found = true; + if(opt.category && opt.category != category) { + category = opt.category; + optgroup = $('').appendTo(select); + } + if(opt.class) + option.addClass(opt.class); + option.appendTo(opt.category ? optgroup : select); + }); + if(!found && !multi) { + var option = $(''); + if(col && col.emptyOption) + option.text(col.emptyOption); + option.attr('value', val); + option.prependTo(select); + } + select.val(val); +} + +/** + * Make an indexed hash from an array + * @param {Array} array + * @param {string} [key] (default 'id') + * @returns {{}} + */ +function hashFromArray(array, key) { + var hash = {}; + if(key == null) key = 'id'; + _.each(array, function(value) { + hash[value[key]] = value; + }); + return hash; +} + +/** + * Build a url which stores the current url and datatable "Show All" parameters as "from" + * @param {string} url base url to go to + * @returns {string} + */ +function getGoto(url) { + var dt = []; + $('button[data-nz]').each(function() { + dt.push($(this).attr('data-nz') == 'true' ? 1 : 0); + }); + var search = urlParameter('dt', dt.toString(), window.location.search); + return urlParameter('from', encodeURIComponent(window.location.pathname + search), url); +} + +/** + * Go to url, storing the current url and datatable "Show All" parameters as "from" + * @param url + */ +function goto(url) { + window.location = getGoto(url); +} + +/** + * Go back to previous url + */ +function goback() { + var from = matchingStatement() ? '/banking/statementmatching.html?id=' + getParameter('id') : getParameter('from'); + if(!from) { + from = window.location.pathname; + var pos = from.substr(1).indexOf('/'); + if(pos >= 0) + from = from.substr(0, pos + 1); + } + window.location = from; +} + +/** + * Get the value of a url parameter + * @param name + * @returns {string} + */ +function getParameter(name) { + var re = new RegExp('[&?]' + name + '=([^&#]*)'); + var m = re.exec(window.location.search); + return m == null || m.length == 0 ? null : decodeURIComponent(m[1]); +} + +//noinspection JSUnusedGlobalSymbols +/** + * Return true if current url has named parameter + * @param name + * @returns {boolean} + */ +function hasParameter(name) { + var re = new RegExp('[&?]' + name + '(=|&|$)'); + var m = re.exec(window.location.search); + return m != null && m.length != 0; +} + +/** + * Return true if currently matching a statement + * @returns {boolean} + */ +function matchingStatement() { + return window.location.pathname == '/banking/statementmatch.html'; +} + +/** + * Add years to a date + * @param {number} y + */ +Date.prototype.addYears = function(y) { + this.setYear(this.getYear() + 1900 + y); +}; + +/** + * Add months to a date + * @param {number} m + */ +Date.prototype.addMonths = function(m) { + var month = (this.getMonth() + m) % 12; + this.setMonth(this.getMonth() + m); + while(this.getMonth() > month) + this.addDays(-1); +}; + +/** + * Add days to a date + * @param {number} d + */ +Date.prototype.addDays = function(d) { + this.setDate(this.getDate() + d); +}; + +/** + * Convert this date to yyyy-mm-dd format + * @returns {string} + */ +Date.prototype.toYMD = function() { + var y = this.getYear() + 1900; + var m = (this.getMonth() + 101).toString().substr(1); + var d = (this.getDate() + 100).toString().substr(1); + return y + "-" + m + "-" + d; +}; \ No newline at end of file diff --git a/html/exception.html b/html/exception.html new file mode 100644 index 0000000..1ad3782 --- /dev/null +++ b/html/exception.html @@ -0,0 +1,17 @@ + + + + + + + + + Accounts - {{{Title}}} + {{Head}} + + +

    There has been an exception

    +
    {{{Exception}}}
    + + + diff --git a/html/favicon.ico b/html/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d24f508813f6ff57e825cefba057531655a0d94c GIT binary patch literal 169468 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 literal 0 HcmV?d00001 diff --git a/html/images/close.png b/html/images/close.png new file mode 100644 index 0000000000000000000000000000000000000000..f948dcd96e874fc58e98a4a0a84523d582b2fcc9 GIT binary patch literal 211 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|jKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1qucL5ULAh?3y^w370~qEv>0#LT=By}Z;C1rt33 zJ=4@yqg0?87f%<*5DWig3poJ?A*nY~|C$-=1WTkRgeq))$k9Dn;gte|FIUHg-;C3< z6c*iJODR2(W8rkMVI@;A%WnA(3l^?dRA698u%F;#eEruppm_|Qu6{1-oD!M<%w0X$ literal 0 HcmV?d00001 diff --git a/html/images/menu.png b/html/images/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..777bada863aeaea99632543f776229e226095f1f GIT binary patch literal 220 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=DjSL74G){)!Z!phSslL`iUdT1k0gQ7S`0VrE{6US4X6f{C7i zo@r{UQ7TZ4ucwP+NQC>_D~?5 literal 0 HcmV?d00001 diff --git a/html/images/nz.png b/html/images/nz.png new file mode 100644 index 0000000000000000000000000000000000000000..b838e224cf20bf5c08d932521c558f317e97074e GIT binary patch literal 349 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=DjSL74G){)!Z!phSslL`iUdT1k0gQ7S`0VrE{6US4X6f{C7i zo{3Ns(`TTXbDl1aAsWH86BPLlIS9CF3vjrwE^1<4z`^d2P$0u_lF@>N{Q*M--$6zX z4-TeA2eSza1Uk4_RG!w(&~?t7@kDj*%>U)}=BGaYVb^M0_gZ>G(;BZG&DJpwnV2U; zG&$eguqBnpbjD%kdr7h{j_5{zSsUK7_3XI`d!C&$*pe#il=ijDT)X6)fmx%t_A$<7 z|9RrpxO*(TWWZD_k~I6Y!B3-ru8pjw?i`PnovmtZ{4_Iv1EcNgx;n;80ruwecNz;O o-*DcYbK*eywU-;kYfmgz7f!sOYM(Ei4D=a;r>mdKI;Vst0E0bziU0rr literal 0 HcmV?d00001 diff --git a/html/images/sort_asc.png b/html/images/sort_asc.png new file mode 100644 index 0000000000000000000000000000000000000000..a88d7975fe9017e4e5f2289a94bd1ed66a5f59dc GIT binary patch literal 1118 zcmbVLO=#0l98awuV{uMt6P_}u4rcI3idKFP2SpU%ZJIE?RL`X zK?Oyo=*5GG2SxDYK=7akJqV&hrl{aWJa`y*5xh+1%i2y4V}gO?edPc9{r;a9vjc}) zn|Cxb4AYwFmvVG%_ui)U^y_4!ujsO!qzYuv8YUIR!Aw%KiWp=JrG#@>(I!s4#N7H->?w+cxsH2#GA};A>g8lyFDGPKh!5)vuP_{)}*83+N zJUBU!S0_i+E{*Lu1iGsNB``2iK-CyCU7?y_mv{xb_pUh>ESZqe1Y2{eAZLMSIT%EO zFrdOH1W^=3p>Qk~I{J+k#s5zQ@j{%aIA!l^GQjJ zqA1Uc2%!{8qBKfMNh#9DCnKS_*uZ8?mnf!+8@f8xtz#prVg=E`3bCBLWsNmDAX~PG z<(4fQh=UOzE2?gKXRkc9XeI3Er?HlHECVd%SI}3`hy1_du3@$R$r(qT;k@Sft63UX zv;)2Ea_iH>^6+4jPK-lGM{Zw37Tz>~~zlHzO61x51(V4jcaKrcIVDG$-d>)z}S|7f!xxYhfUE}Kj zug_h&HZN}go22$5Ym1}P8~vYNx7-~$TWFJ;_nh!wFYSAQJF{CCo=xpK8^7?iY1^!H haOA^1D_`VC7fU=jcT literal 0 HcmV?d00001 diff --git a/html/images/sort_asc_disabled.png b/html/images/sort_asc_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd7b7b8cab2304b374e6e4b9dc8c05faa2e1130 GIT binary patch literal 2916 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 literal 0 HcmV?d00001 diff --git a/html/images/sort_desc.png b/html/images/sort_desc.png new file mode 100644 index 0000000000000000000000000000000000000000..def071ed5afd264a036f6d9e75856366fd6ad153 GIT binary patch literal 1127 zcmbVMOK8+U7*1U&zKRu5sR)h{1;yRWWV^4}ShvZpU2*HWU2!iy(qy)cZ89;Lb+`3m zMbruv!GjkO!3qksP*5)lD)k}=Dp*ht-n@8G5m8XoN!zU+ih_Y;=AZe$?|)|~*Ri8v z(dtDU$2DZy)jV65`|pB!_H}d7Cv0h=sUqzpC0fy3%q0!dg+a#Bx^W(BM*oq=xP{{a zC9_bZ#q2IgCss)FbwX9kVQ7wPX{|b%-is;d!ri7V^Y8E8=YeU+{JuyQW*r6hnC$~D z?i}bS=mWia!r)uCftISo2rNuBP__DOPpZoN6tBeg{;|M=DHYl)^V3chvpJv;7lTL$ z26Y&PAc{gL+#HL=wg3?#C_qs_Vi3iouqZ(YW*(kdbB&UeSJN}Lm?ZN(lsb|iR4SEF zB^)Adw}29fgwG+0L8cM(`faLJgSNN6#-L(PcTI+l@K3y+Xf(g*^61+0|J+O6zN2mb?UNGh6GU@A{1+eF%d@N2(^XdVmhis(y25|iAr;gV=io5OsYy0 zB}Gv|2&GUGrBPB%s*yG^841Ug8a88lRI_zlvuiTDGuXsmv6A9qjS{y&NMEf3ay^6+ zuZK85>5PD^rkl1e`{kLAR>iJ)6dP%mSYRr@k~xQcDE=$%X{_--ITM&Og5Ml}G)wJ> zb)dhUZG9%p4iC23#JFrUCcmwHz{cugMoku~ue-kg{Mj0~%`FeCcz9jAdg}QET-kSG za`+2B_+lRTaeAVz>E`F1pN7h>B=BbGqcz13d%ywZR&4OjkNNrF_U}#EcXDGa@V52B z>JnIW7#s%CHi literal 0 HcmV?d00001 diff --git a/html/images/sort_desc_disabled.png b/html/images/sort_desc_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..7824973cc60fc1841b16f2cb39323cefcdc3f942 GIT binary patch literal 1045 zcmaJ=&rj1(9IuWjVlWt@h#q(rlc~7%$2P_q>KN??ODrK{#&I!}_Kh{rzS=%m2N%F- zAW={L0VZBJnRrkSCK{q1NKA||(ZmA>6Hgw9o;Z-;>)3_|u*vIt-(X0AeGY5Bm`Mgoq{>2>Xkbiu%Ds= zw2?31f^tL9kQr8eOxQDR!ltPHq-U$zG{j&MP8pU+Z@qp?149?-TQP-IYzdZ(;duv+ z&5z`@`Drbo)5+_g-xG*{39$-1bH;K7Po%550y+EF3=OIfJT20DK^2ryARz~WSeOlI zY%dFXxiA-r#^dp8fM+?DVR?q*LtI>l@B+(%+D8*_j$RaUa;D~sSR!4**cKS3TrP*p zkuY+m7%q`W_!>MPB8ZS%v9RieEVsL^AVXJk3>zEB0=}X;iDt1#lSubcFztq{<<`nX z3dVS<&2VAXPpJ-6l>b9bvw?PT4(`W$ps<^-*pSIV7tJ~vX67YQ8ELa7v~ZoP?{i~^a{W;-ZQ@ymjxh)IjDt*2O<6Dwh=q$vY$VY; zc&J{Ds~-?cjVm3>Wk@iL-`IZ|UB4pJ;~yJiON_?gLyJtiL&kbxZhV_OiPfx}%6s1@ zcXoG^ffrPJ;LQ4(`t<(ickJ1j|E0&fC8lSh8sUh5lwUg=l~QoqsK t`nTanN|e2@a&yVMdhyf4F%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?Unko^*g zyRyqaC@tD=;kDt~qA5ZTkJa@we4n@FTFTnHubpQUt1T5VK6c}k*tTmCU+$@DE#1Ie zb423jK}+>O4o4-A)!QcOwS?k)_>#w|r1Kptm-M`SUO z_5fqIli7AahM1>|V~EA+ zRdP`(kYX@0Ff`RQFw`|P3^BB@GBvd_G}JY)ure@sHt)FuiiX_$l+3hB+#04Ij{gJH Oz~JfX=d#Wzp$P!OY&7fu literal 0 HcmV?d00001 diff --git a/html/images/ui-bg_flat_75_ffffff_40x100.png b/html/images/ui-bg_flat_75_ffffff_40x100.png new file mode 100644 index 0000000000000000000000000000000000000000..25657ea86422bbd43e2bc33a6abe7920a090df4d GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F2qYNp$opRhQcOwS?k)_Bce{j_0C}7R9+AaB z+5?Q;PG;Ky8A6^ejv*T7lYj6t@hpC#;TbB#aBAWwna#KLs)4eqC9V-ADTyViR>?)F zK#IZ0z|d6Jz);uFFvQTp%GA`#&`{UF!pgwl*}UftC>nC}Q!>*kach`nc6a#?2AmP!?*K(O3p^r= zfwTu0yPeFo12TF&T^vI^j=w#x$i?I+((tf;UXnmgbH|3oY>pC!)f}(GR!16S-u+#{ ze6YEqRkW=8vGl=5qArKM<9}TC-}iEvB{zdaTcX5$wyRTK&ALKYn`7+P4Fnpzo{=o(m985mUXoTx<6kei>9nO2Eg17~2tQJ@9}Pgg&e IbxsLQ0Oy-Bga7~l literal 0 HcmV?d00001 diff --git a/html/images/ui-bg_glass_75_dadada_1x400.png b/html/images/ui-bg_glass_75_dadada_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..497a94788cbac611ea86fba04c1c22db5e351cbd GIT binary patch literal 262 zcmeAS@N?(olHy`uVBq!ia0vp^j6gI&0LWmFTHNUZq?nSt-Ch3w7g=q17Rci)@Q5r1 z(jH*!b~4)z#PD=+46!(!T=8puqDZgOs>RXUCGx5b?-VBQkUm|IuXOmYJrBRJgj{Vx zMbNnqUkncy+qa2-mWYc>swkcIuvGK#>(0d)B7)5f`@$Ei28nH~0h*~=;u=wsl30>z zm0Xkxq!^403{7Eq9HdwB{QuOw}$D598c2# literal 0 HcmV?d00001 diff --git a/html/images/ui-bg_glass_75_e6e6e6_1x400.png b/html/images/ui-bg_glass_75_e6e6e6_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..5e7d1c1efa4fdc31cf47135528cd92f1a6c52e75 GIT binary patch literal 262 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 QJ~;stWz}Trq%1=J3#jBGg8%>k literal 0 HcmV?d00001 diff --git a/html/images/ui-icons_2e83ff_256x240.png b/html/images/ui-icons_2e83ff_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..84b601bf0f726bf95801da487deaf2344a32e4b8 GIT binary patch literal 4549 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 F{{S81aohj^ literal 0 HcmV?d00001 diff --git a/html/images/ui-icons_454545_256x240.png b/html/images/ui-icons_454545_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..b6db1acdd433be80a472b045018f25c7f2cf7e08 GIT binary patch literal 6992 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 literal 0 HcmV?d00001 diff --git a/html/images/ui-icons_cd0a0a_256x240.png b/html/images/ui-icons_cd0a0a_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..ed5b6b0930f672fa08e9b9bdbe5e55370fd1dc30 GIT binary patch literal 4549 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 DTfr$v literal 0 HcmV?d00001 diff --git a/html/images/untick.png b/html/images/untick.png new file mode 100644 index 0000000000000000000000000000000000000000..2b4c44ba2a34ba698d2cf0546005cb520be3fba1 GIT binary patch literal 182 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4F%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?<7pz`$`p;eUOVoWene#8u+U3|cjP61jqB91?kURp-M= WttZ~k_N4)}FnGH9xvX!lvI6;>1s;*b3=DjSL74G){)!Z!phSslL`iUdT1k0gQ7S`0VrE{6US4X6f{C7i zo{3Ns(`TTXO`a}}AsWHSKmPx>XJcdHR4_ZB)qbc^!-oIJ{>C4+0{hvLq(w|MWZ2r? zI@CyXur|zK?NDd_n6B{b-~|R2BL|)&3x&>)4o8z7Bza7-3-IKdArbP&P{91aPY)qZ z2c8rq5$B`tnGH)#SQrdD4l)ThcQuih_%^|S)3?sLLkGO+)`-A^H3`;6xZdwax{sOw5!PC{xWt~$( F6991XWmf + + + + + + + + + + + + + +{{#with Record}} + +{{/with}} + + +
    Loading...
    + diff --git a/html/investments/default.html b/html/investments/default.html new file mode 100644 index 0000000..5cf38e9 --- /dev/null +++ b/html/investments/default.html @@ -0,0 +1,32 @@ + + + + + + +
    +
    + + \ No newline at end of file diff --git a/html/investments/detail.html b/html/investments/detail.html new file mode 100644 index 0000000..ecbc938 --- /dev/null +++ b/html/investments/detail.html @@ -0,0 +1,85 @@ +{{#with Record}} + + {{/with}} + + +
    Cash balance{{Record.CashBalance:0.00}}Current cash balance{{Record.CurrentBalance:0.00}}Current value{{Record.Value:0.00}}
    +
    +
    + diff --git a/html/investments/document.html b/html/investments/document.html new file mode 100644 index 0000000..f4940b9 --- /dev/null +++ b/html/investments/document.html @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + +{{#with Record}} + +{{/with}} + + +
    + diff --git a/html/investments/portfolio.html b/html/investments/portfolio.html new file mode 100644 index 0000000..248bd03 --- /dev/null +++ b/html/investments/portfolio.html @@ -0,0 +1,45 @@ +{{#with Record}} + + {{/with}} + + +
    Cash balance{{Record.CashBalance:0.00}}Current cash balance{{Record.CurrentBalance:0.00}}Current value{{Record.Value:0.00}}
    +
    + diff --git a/html/investments/securities.html b/html/investments/securities.html new file mode 100644 index 0000000..64d0422 --- /dev/null +++ b/html/investments/securities.html @@ -0,0 +1,22 @@ + + + + + + +
    +
    + + \ No newline at end of file diff --git a/html/investments/security.html b/html/investments/security.html new file mode 100644 index 0000000..ab17479 --- /dev/null +++ b/html/investments/security.html @@ -0,0 +1,76 @@ + + + + +
    Security{{Record.header.SecurityName}}Ticker{{Record.header.Ticker}}
    +
    +
    + \ No newline at end of file diff --git a/html/jquery-ui.js b/html/jquery-ui.js new file mode 100644 index 0000000..1bd512c --- /dev/null +++ b/html/jquery-ui.js @@ -0,0 +1,16582 @@ +/*! jQuery UI - v1.11.2 - 2015-01-11 +* http://jqueryui.com +* Includes: core.js, widget.js, mouse.js, position.js, draggable.js, droppable.js, resizable.js, selectable.js, sortable.js, accordion.js, autocomplete.js, button.js, datepicker.js, dialog.js, menu.js, progressbar.js, selectmenu.js, slider.js, spinner.js, tabs.js, tooltip.js, effect.js, effect-blind.js, effect-bounce.js, effect-clip.js, effect-drop.js, effect-explode.js, effect-fade.js, effect-fold.js, effect-highlight.js, effect-puff.js, effect-pulsate.js, effect-scale.js, effect-shake.js, effect-size.js, effect-slide.js, effect-transfer.js +* Copyright 2015 jQuery Foundation and other contributors; Licensed MIT */ + +(function( factory ) { + if ( typeof define === "function" && define.amd ) { + + // AMD. Register as an anonymous module. + define([ "jquery" ], factory ); + } else { + + // Browser globals + factory( jQuery ); + } +}(function( $ ) { +/*! + * jQuery UI Core 1.11.2 + * http://jqueryui.com + * + * Copyright 2014 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/category/ui-core/ + */ + + +// $.ui might exist from components with no dependencies, e.g., $.ui.position +$.ui = $.ui || {}; + +$.extend( $.ui, { + version: "1.11.2", + + keyCode: { + BACKSPACE: 8, + COMMA: 188, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + LEFT: 37, + PAGE_DOWN: 34, + PAGE_UP: 33, + PERIOD: 190, + RIGHT: 39, + SPACE: 32, + TAB: 9, + UP: 38 + } +}); + +// plugins +$.fn.extend({ + scrollParent: function( includeHidden ) { + var position = this.css( "position" ), + excludeStaticParent = position === "absolute", + overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/, + scrollParent = this.parents().filter( function() { + var parent = $( this ); + if ( excludeStaticParent && parent.css( "position" ) === "static" ) { + return false; + } + return overflowRegex.test( parent.css( "overflow" ) + parent.css( "overflow-y" ) + parent.css( "overflow-x" ) ); + }).eq( 0 ); + + return position === "fixed" || !scrollParent.length ? $( this[ 0 ].ownerDocument || document ) : scrollParent; + }, + + uniqueId: (function() { + var uuid = 0; + + return function() { + return this.each(function() { + if ( !this.id ) { + this.id = "ui-id-" + ( ++uuid ); + } + }); + }; + })(), + + removeUniqueId: function() { + return this.each(function() { + if ( /^ui-id-\d+$/.test( this.id ) ) { + $( this ).removeAttr( "id" ); + } + }); + } +}); + +// selectors +function focusable( element, isTabIndexNotNaN ) { + var map, mapName, img, + nodeName = element.nodeName.toLowerCase(); + if ( "area" === nodeName ) { + map = element.parentNode; + mapName = map.name; + if ( !element.href || !mapName || map.nodeName.toLowerCase() !== "map" ) { + return false; + } + img = $( "img[usemap='#" + mapName + "']" )[ 0 ]; + return !!img && visible( img ); + } + return ( /input|select|textarea|button|object/.test( nodeName ) ? + !element.disabled : + "a" === nodeName ? + element.href || isTabIndexNotNaN : + isTabIndexNotNaN) && + // the element and all of its ancestors must be visible + visible( element ); +} + +function visible( element ) { + return $.expr.filters.visible( element ) && + !$( element ).parents().addBack().filter(function() { + return $.css( this, "visibility" ) === "hidden"; + }).length; +} + +$.extend( $.expr[ ":" ], { + data: $.expr.createPseudo ? + $.expr.createPseudo(function( dataName ) { + return function( elem ) { + return !!$.data( elem, dataName ); + }; + }) : + // support: jQuery <1.8 + function( elem, i, match ) { + return !!$.data( elem, match[ 3 ] ); + }, + + focusable: function( element ) { + return focusable( element, !isNaN( $.attr( element, "tabindex" ) ) ); + }, + + tabbable: function( element ) { + var tabIndex = $.attr( element, "tabindex" ), + isTabIndexNaN = isNaN( tabIndex ); + return ( isTabIndexNaN || tabIndex >= 0 ) && focusable( element, !isTabIndexNaN ); + } +}); + +// support: jQuery <1.8 +if ( !$( "" ).outerWidth( 1 ).jquery ) { + $.each( [ "Width", "Height" ], function( i, name ) { + var side = name === "Width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ], + type = name.toLowerCase(), + orig = { + innerWidth: $.fn.innerWidth, + innerHeight: $.fn.innerHeight, + outerWidth: $.fn.outerWidth, + outerHeight: $.fn.outerHeight + }; + + function reduce( elem, size, border, margin ) { + $.each( side, function() { + size -= parseFloat( $.css( elem, "padding" + this ) ) || 0; + if ( border ) { + size -= parseFloat( $.css( elem, "border" + this + "Width" ) ) || 0; + } + if ( margin ) { + size -= parseFloat( $.css( elem, "margin" + this ) ) || 0; + } + }); + return size; + } + + $.fn[ "inner" + name ] = function( size ) { + if ( size === undefined ) { + return orig[ "inner" + name ].call( this ); + } + + return this.each(function() { + $( this ).css( type, reduce( this, size ) + "px" ); + }); + }; + + $.fn[ "outer" + name] = function( size, margin ) { + if ( typeof size !== "number" ) { + return orig[ "outer" + name ].call( this, size ); + } + + return this.each(function() { + $( this).css( type, reduce( this, size, true, margin ) + "px" ); + }); + }; + }); +} + +// support: jQuery <1.8 +if ( !$.fn.addBack ) { + $.fn.addBack = function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter( selector ) + ); + }; +} + +// support: jQuery 1.6.1, 1.6.2 (http://bugs.jquery.com/ticket/9413) +if ( $( "" ).data( "a-b", "a" ).removeData( "a-b" ).data( "a-b" ) ) { + $.fn.removeData = (function( removeData ) { + return function( key ) { + if ( arguments.length ) { + return removeData.call( this, $.camelCase( key ) ); + } else { + return removeData.call( this ); + } + }; + })( $.fn.removeData ); +} + +// deprecated +$.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ); + +$.fn.extend({ + focus: (function( orig ) { + return function( delay, fn ) { + return typeof delay === "number" ? + this.each(function() { + var elem = this; + setTimeout(function() { + $( elem ).focus(); + if ( fn ) { + fn.call( elem ); + } + }, delay ); + }) : + orig.apply( this, arguments ); + }; + })( $.fn.focus ), + + disableSelection: (function() { + var eventType = "onselectstart" in document.createElement( "div" ) ? + "selectstart" : + "mousedown"; + + return function() { + return this.bind( eventType + ".ui-disableSelection", function( event ) { + event.preventDefault(); + }); + }; + })(), + + enableSelection: function() { + return this.unbind( ".ui-disableSelection" ); + }, + + zIndex: function( zIndex ) { + if ( zIndex !== undefined ) { + return this.css( "zIndex", zIndex ); + } + + if ( this.length ) { + var elem = $( this[ 0 ] ), position, value; + while ( elem.length && elem[ 0 ] !== document ) { + // Ignore z-index if position is set to a value where z-index is ignored by the browser + // This makes behavior of this function consistent across browsers + // WebKit always returns auto if the element is positioned + position = elem.css( "position" ); + if ( position === "absolute" || position === "relative" || position === "fixed" ) { + // IE returns 0 when zIndex is not specified + // other browsers return a string + // we ignore the case of nested elements with an explicit value of 0 + //
    + value = parseInt( elem.css( "zIndex" ), 10 ); + if ( !isNaN( value ) && value !== 0 ) { + return value; + } + } + elem = elem.parent(); + } + } + + return 0; + } +}); + +// $.ui.plugin is deprecated. Use $.widget() extensions instead. +$.ui.plugin = { + add: function( module, option, set ) { + var i, + proto = $.ui[ module ].prototype; + for ( i in set ) { + proto.plugins[ i ] = proto.plugins[ i ] || []; + proto.plugins[ i ].push( [ option, set[ i ] ] ); + } + }, + call: function( instance, name, args, allowDisconnected ) { + var i, + set = instance.plugins[ name ]; + + if ( !set ) { + return; + } + + if ( !allowDisconnected && ( !instance.element[ 0 ].parentNode || instance.element[ 0 ].parentNode.nodeType === 11 ) ) { + return; + } + + for ( i = 0; i < set.length; i++ ) { + if ( instance.options[ set[ i ][ 0 ] ] ) { + set[ i ][ 1 ].apply( instance.element, args ); + } + } + } +}; + + +/*! + * jQuery UI Widget 1.11.2 + * http://jqueryui.com + * + * Copyright 2014 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/jQuery.widget/ + */ + + +var widget_uuid = 0, + widget_slice = Array.prototype.slice; + +$.cleanData = (function( orig ) { + return function( elems ) { + var events, elem, i; + for ( i = 0; (elem = elems[i]) != null; i++ ) { + try { + + // Only trigger remove when necessary to save time + events = $._data( elem, "events" ); + if ( events && events.remove ) { + $( elem ).triggerHandler( "remove" ); + } + + // http://bugs.jquery.com/ticket/8235 + } catch ( e ) {} + } + orig( elems ); + }; +})( $.cleanData ); + +$.widget = function( name, base, prototype ) { + var fullName, existingConstructor, constructor, basePrototype, + // proxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + proxiedPrototype = {}, + namespace = name.split( "." )[ 0 ]; + + name = name.split( "." )[ 1 ]; + fullName = namespace + "-" + name; + + if ( !prototype ) { + prototype = base; + base = $.Widget; + } + + // create selector for plugin + $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { + return !!$.data( elem, fullName ); + }; + + $[ namespace ] = $[ namespace ] || {}; + existingConstructor = $[ namespace ][ name ]; + constructor = $[ namespace ][ name ] = function( options, element ) { + // allow instantiation without "new" keyword + if ( !this._createWidget ) { + return new constructor( options, element ); + } + + // allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) + if ( arguments.length ) { + this._createWidget( options, element ); + } + }; + // extend with the existing constructor to carry over any static properties + $.extend( constructor, existingConstructor, { + version: prototype.version, + // copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend( {}, prototype ), + // track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + }); + + basePrototype = new base(); + // we need to make the options hash a property directly on the new instance + // otherwise we'll modify the options hash on the prototype that we're + // inheriting from + basePrototype.options = $.widget.extend( {}, basePrototype.options ); + $.each( prototype, function( prop, value ) { + if ( !$.isFunction( value ) ) { + proxiedPrototype[ prop ] = value; + return; + } + proxiedPrototype[ prop ] = (function() { + var _super = function() { + return base.prototype[ prop ].apply( this, arguments ); + }, + _superApply = function( args ) { + return base.prototype[ prop ].apply( this, args ); + }; + return function() { + var __super = this._super, + __superApply = this._superApply, + returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply( this, arguments ); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + })(); + }); + constructor.prototype = $.widget.extend( basePrototype, { + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor ? (basePrototype.widgetEventPrefix || name) : name + }, proxiedPrototype, { + constructor: constructor, + namespace: namespace, + widgetName: name, + widgetFullName: fullName + }); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if ( existingConstructor ) { + $.each( existingConstructor._childConstructors, function( i, child ) { + var childPrototype = child.prototype; + + // redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto ); + }); + // remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; + } else { + base._childConstructors.push( constructor ); + } + + $.widget.bridge( name, constructor ); + + return constructor; +}; + +$.widget.extend = function( target ) { + var input = widget_slice.call( arguments, 1 ), + inputIndex = 0, + inputLength = input.length, + key, + value; + for ( ; inputIndex < inputLength; inputIndex++ ) { + for ( key in input[ inputIndex ] ) { + value = input[ inputIndex ][ key ]; + if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { + // Clone objects + if ( $.isPlainObject( value ) ) { + target[ key ] = $.isPlainObject( target[ key ] ) ? + $.widget.extend( {}, target[ key ], value ) : + // Don't extend strings, arrays, etc. with objects + $.widget.extend( {}, value ); + // Copy everything else by reference + } else { + target[ key ] = value; + } + } + } + } + return target; +}; + +$.widget.bridge = function( name, object ) { + var fullName = object.prototype.widgetFullName || name; + $.fn[ name ] = function( options ) { + var isMethodCall = typeof options === "string", + args = widget_slice.call( arguments, 1 ), + returnValue = this; + + // allow multiple hashes to be passed on init + options = !isMethodCall && args.length ? + $.widget.extend.apply( null, [ options ].concat(args) ) : + options; + + if ( isMethodCall ) { + this.each(function() { + var methodValue, + instance = $.data( this, fullName ); + if ( options === "instance" ) { + returnValue = instance; + return false; + } + if ( !instance ) { + return $.error( "cannot call methods on " + name + " prior to initialization; " + + "attempted to call method '" + options + "'" ); + } + if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) { + return $.error( "no such method '" + options + "' for " + name + " widget instance" ); + } + methodValue = instance[ options ].apply( instance, args ); + if ( methodValue !== instance && methodValue !== undefined ) { + returnValue = methodValue && methodValue.jquery ? + returnValue.pushStack( methodValue.get() ) : + methodValue; + return false; + } + }); + } else { + this.each(function() { + var instance = $.data( this, fullName ); + if ( instance ) { + instance.option( options || {} ); + if ( instance._init ) { + instance._init(); + } + } else { + $.data( this, fullName, new object( options, this ) ); + } + }); + } + + return returnValue; + }; +}; + +$.Widget = function( /* options, element */ ) {}; +$.Widget._childConstructors = []; + +$.Widget.prototype = { + widgetName: "widget", + widgetEventPrefix: "", + defaultElement: "
    ", + options: { + disabled: false, + + // callbacks + create: null + }, + _createWidget: function( options, element ) { + element = $( element || this.defaultElement || this )[ 0 ]; + this.element = $( element ); + this.uuid = widget_uuid++; + this.eventNamespace = "." + this.widgetName + this.uuid; + + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + + if ( element !== this ) { + $.data( element, this.widgetFullName, this ); + this._on( true, this.element, { + remove: function( event ) { + if ( event.target === element ) { + this.destroy(); + } + } + }); + this.document = $( element.style ? + // element within the document + element.ownerDocument : + // element is window or document + element.document || element ); + this.window = $( this.document[0].defaultView || this.document[0].parentWindow ); + } + + this.options = $.widget.extend( {}, + this.options, + this._getCreateOptions(), + options ); + + this._create(); + this._trigger( "create", null, this._getCreateEventData() ); + this._init(); + }, + _getCreateOptions: $.noop, + _getCreateEventData: $.noop, + _create: $.noop, + _init: $.noop, + + destroy: function() { + this._destroy(); + // we can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() + this.element + .unbind( this.eventNamespace ) + .removeData( this.widgetFullName ) + // support: jquery <1.6.3 + // http://bugs.jquery.com/ticket/9413 + .removeData( $.camelCase( this.widgetFullName ) ); + this.widget() + .unbind( this.eventNamespace ) + .removeAttr( "aria-disabled" ) + .removeClass( + this.widgetFullName + "-disabled " + + "ui-state-disabled" ); + + // clean up events and states + this.bindings.unbind( this.eventNamespace ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); + }, + _destroy: $.noop, + + widget: function() { + return this.element; + }, + + option: function( key, value ) { + var options = key, + parts, + curOption, + i; + + if ( arguments.length === 0 ) { + // don't return a reference to the internal hash + return $.widget.extend( {}, this.options ); + } + + if ( typeof key === "string" ) { + // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } + options = {}; + parts = key.split( "." ); + key = parts.shift(); + if ( parts.length ) { + curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); + for ( i = 0; i < parts.length - 1; i++ ) { + curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; + curOption = curOption[ parts[ i ] ]; + } + key = parts.pop(); + if ( arguments.length === 1 ) { + return curOption[ key ] === undefined ? null : curOption[ key ]; + } + curOption[ key ] = value; + } else { + if ( arguments.length === 1 ) { + return this.options[ key ] === undefined ? null : this.options[ key ]; + } + options[ key ] = value; + } + } + + this._setOptions( options ); + + return this; + }, + _setOptions: function( options ) { + var key; + + for ( key in options ) { + this._setOption( key, options[ key ] ); + } + + return this; + }, + _setOption: function( key, value ) { + this.options[ key ] = value; + + if ( key === "disabled" ) { + this.widget() + .toggleClass( this.widgetFullName + "-disabled", !!value ); + + // If the widget is becoming disabled, then nothing is interactive + if ( value ) { + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); + } + } + + return this; + }, + + enable: function() { + return this._setOptions({ disabled: false }); + }, + disable: function() { + return this._setOptions({ disabled: true }); + }, + + _on: function( suppressDisabledCheck, element, handlers ) { + var delegateElement, + instance = this; + + // no suppressDisabledCheck flag, shuffle arguments + if ( typeof suppressDisabledCheck !== "boolean" ) { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // no element argument, shuffle and use this.element + if ( !handlers ) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + element = delegateElement = $( element ); + this.bindings = this.bindings.add( element ); + } + + $.each( handlers, function( event, handler ) { + function handlerProxy() { + // allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( !suppressDisabledCheck && + ( instance.options.disabled === true || + $( this ).hasClass( "ui-state-disabled" ) ) ) { + return; + } + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + + // copy the guid so direct unbinding works + if ( typeof handler !== "string" ) { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match( /^([\w:-]*)\s*(.*)$/ ), + eventName = match[1] + instance.eventNamespace, + selector = match[2]; + if ( selector ) { + delegateElement.delegate( selector, eventName, handlerProxy ); + } else { + element.bind( eventName, handlerProxy ); + } + }); + }, + + _off: function( element, eventName ) { + eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + + this.eventNamespace; + element.unbind( eventName ).undelegate( eventName ); + + // Clear the stack to avoid memory leaks (#10056) + this.bindings = $( this.bindings.not( element ).get() ); + this.focusable = $( this.focusable.not( element ).get() ); + this.hoverable = $( this.hoverable.not( element ).get() ); + }, + + _delay: function( handler, delay ) { + function handlerProxy() { + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + var instance = this; + return setTimeout( handlerProxy, delay || 0 ); + }, + + _hoverable: function( element ) { + this.hoverable = this.hoverable.add( element ); + this._on( element, { + mouseenter: function( event ) { + $( event.currentTarget ).addClass( "ui-state-hover" ); + }, + mouseleave: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-hover" ); + } + }); + }, + + _focusable: function( element ) { + this.focusable = this.focusable.add( element ); + this._on( element, { + focusin: function( event ) { + $( event.currentTarget ).addClass( "ui-state-focus" ); + }, + focusout: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-focus" ); + } + }); + }, + + _trigger: function( type, event, data ) { + var prop, orig, + callback = this.options[ type ]; + + data = data || {}; + event = $.Event( event ); + event.type = ( type === this.widgetEventPrefix ? + type : + this.widgetEventPrefix + type ).toLowerCase(); + // the original event may come from any element + // so we need to reset the target on the new event + event.target = this.element[ 0 ]; + + // copy original event properties over to the new event + orig = event.originalEvent; + if ( orig ) { + for ( prop in orig ) { + if ( !( prop in event ) ) { + event[ prop ] = orig[ prop ]; + } + } + } + + this.element.trigger( event, data ); + return !( $.isFunction( callback ) && + callback.apply( this.element[0], [ event ].concat( data ) ) === false || + event.isDefaultPrevented() ); + } +}; + +$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { + $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { + if ( typeof options === "string" ) { + options = { effect: options }; + } + var hasOptions, + effectName = !options ? + method : + options === true || typeof options === "number" ? + defaultEffect : + options.effect || defaultEffect; + options = options || {}; + if ( typeof options === "number" ) { + options = { duration: options }; + } + hasOptions = !$.isEmptyObject( options ); + options.complete = callback; + if ( options.delay ) { + element.delay( options.delay ); + } + if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { + element[ method ]( options ); + } else if ( effectName !== method && element[ effectName ] ) { + element[ effectName ]( options.duration, options.easing, callback ); + } else { + element.queue(function( next ) { + $( this )[ method ](); + if ( callback ) { + callback.call( element[ 0 ] ); + } + next(); + }); + } + }; +}); + +var widget = $.widget; + + +/*! + * jQuery UI Mouse 1.11.2 + * http://jqueryui.com + * + * Copyright 2014 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/mouse/ + */ + + +var mouseHandled = false; +$( document ).mouseup( function() { + mouseHandled = false; +}); + +var mouse = $.widget("ui.mouse", { + version: "1.11.2", + options: { + cancel: "input,textarea,button,select,option", + distance: 1, + delay: 0 + }, + _mouseInit: function() { + var that = this; + + this.element + .bind("mousedown." + this.widgetName, function(event) { + return that._mouseDown(event); + }) + .bind("click." + this.widgetName, function(event) { + if (true === $.data(event.target, that.widgetName + ".preventClickEvent")) { + $.removeData(event.target, that.widgetName + ".preventClickEvent"); + event.stopImmediatePropagation(); + return false; + } + }); + + this.started = false; + }, + + // TODO: make sure destroying one instance of mouse doesn't mess with + // other instances of mouse + _mouseDestroy: function() { + this.element.unbind("." + this.widgetName); + if ( this._mouseMoveDelegate ) { + this.document + .unbind("mousemove." + this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup." + this.widgetName, this._mouseUpDelegate); + } + }, + + _mouseDown: function(event) { + // don't let more than one widget handle mouseStart + if ( mouseHandled ) { + return; + } + + this._mouseMoved = false; + + // we may have missed mouseup (out of window) + (this._mouseStarted && this._mouseUp(event)); + + this._mouseDownEvent = event; + + var that = this, + btnIsLeft = (event.which === 1), + // event.target.nodeName works around a bug in IE 8 with + // disabled inputs (#7620) + elIsCancel = (typeof this.options.cancel === "string" && event.target.nodeName ? $(event.target).closest(this.options.cancel).length : false); + if (!btnIsLeft || elIsCancel || !this._mouseCapture(event)) { + return true; + } + + this.mouseDelayMet = !this.options.delay; + if (!this.mouseDelayMet) { + this._mouseDelayTimer = setTimeout(function() { + that.mouseDelayMet = true; + }, this.options.delay); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = (this._mouseStart(event) !== false); + if (!this._mouseStarted) { + event.preventDefault(); + return true; + } + } + + // Click event may never have fired (Gecko & Opera) + if (true === $.data(event.target, this.widgetName + ".preventClickEvent")) { + $.removeData(event.target, this.widgetName + ".preventClickEvent"); + } + + // these delegates are required to keep context + this._mouseMoveDelegate = function(event) { + return that._mouseMove(event); + }; + this._mouseUpDelegate = function(event) { + return that._mouseUp(event); + }; + + this.document + .bind( "mousemove." + this.widgetName, this._mouseMoveDelegate ) + .bind( "mouseup." + this.widgetName, this._mouseUpDelegate ); + + event.preventDefault(); + + mouseHandled = true; + return true; + }, + + _mouseMove: function(event) { + // Only check for mouseups outside the document if you've moved inside the document + // at least once. This prevents the firing of mouseup in the case of IE<9, which will + // fire a mousemove event if content is placed under the cursor. See #7778 + // Support: IE <9 + if ( this._mouseMoved ) { + // IE mouseup check - mouseup happened when mouse was out of window + if ($.ui.ie && ( !document.documentMode || document.documentMode < 9 ) && !event.button) { + return this._mouseUp(event); + + // Iframe mouseup check - mouseup occurred in another document + } else if ( !event.which ) { + return this._mouseUp( event ); + } + } + + if ( event.which || event.button ) { + this._mouseMoved = true; + } + + if (this._mouseStarted) { + this._mouseDrag(event); + return event.preventDefault(); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = + (this._mouseStart(this._mouseDownEvent, event) !== false); + (this._mouseStarted ? this._mouseDrag(event) : this._mouseUp(event)); + } + + return !this._mouseStarted; + }, + + _mouseUp: function(event) { + this.document + .unbind( "mousemove." + this.widgetName, this._mouseMoveDelegate ) + .unbind( "mouseup." + this.widgetName, this._mouseUpDelegate ); + + if (this._mouseStarted) { + this._mouseStarted = false; + + if (event.target === this._mouseDownEvent.target) { + $.data(event.target, this.widgetName + ".preventClickEvent", true); + } + + this._mouseStop(event); + } + + mouseHandled = false; + return false; + }, + + _mouseDistanceMet: function(event) { + return (Math.max( + Math.abs(this._mouseDownEvent.pageX - event.pageX), + Math.abs(this._mouseDownEvent.pageY - event.pageY) + ) >= this.options.distance + ); + }, + + _mouseDelayMet: function(/* event */) { + return this.mouseDelayMet; + }, + + // These are placeholder methods, to be overriden by extending plugin + _mouseStart: function(/* event */) {}, + _mouseDrag: function(/* event */) {}, + _mouseStop: function(/* event */) {}, + _mouseCapture: function(/* event */) { return true; } +}); + + +/*! + * jQuery UI Position 1.11.2 + * http://jqueryui.com + * + * Copyright 2014 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/position/ + */ + +(function() { + +$.ui = $.ui || {}; + +var cachedScrollbarWidth, supportsOffsetFractions, + max = Math.max, + abs = Math.abs, + round = Math.round, + rhorizontal = /left|center|right/, + rvertical = /top|center|bottom/, + roffset = /[\+\-]\d+(\.[\d]+)?%?/, + rposition = /^\w+/, + rpercent = /%$/, + _position = $.fn.position; + +function getOffsets( offsets, width, height ) { + return [ + parseFloat( offsets[ 0 ] ) * ( rpercent.test( offsets[ 0 ] ) ? width / 100 : 1 ), + parseFloat( offsets[ 1 ] ) * ( rpercent.test( offsets[ 1 ] ) ? height / 100 : 1 ) + ]; +} + +function parseCss( element, property ) { + return parseInt( $.css( element, property ), 10 ) || 0; +} + +function getDimensions( elem ) { + var raw = elem[0]; + if ( raw.nodeType === 9 ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: 0, left: 0 } + }; + } + if ( $.isWindow( raw ) ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: elem.scrollTop(), left: elem.scrollLeft() } + }; + } + if ( raw.preventDefault ) { + return { + width: 0, + height: 0, + offset: { top: raw.pageY, left: raw.pageX } + }; + } + return { + width: elem.outerWidth(), + height: elem.outerHeight(), + offset: elem.offset() + }; +} + +$.position = { + scrollbarWidth: function() { + if ( cachedScrollbarWidth !== undefined ) { + return cachedScrollbarWidth; + } + var w1, w2, + div = $( "
    " ), + innerDiv = div.children()[0]; + + $( "body" ).append( div ); + w1 = innerDiv.offsetWidth; + div.css( "overflow", "scroll" ); + + w2 = innerDiv.offsetWidth; + + if ( w1 === w2 ) { + w2 = div[0].clientWidth; + } + + div.remove(); + + return (cachedScrollbarWidth = w1 - w2); + }, + getScrollInfo: function( within ) { + var overflowX = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-x" ), + overflowY = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-y" ), + hasOverflowX = overflowX === "scroll" || + ( overflowX === "auto" && within.width < within.element[0].scrollWidth ), + hasOverflowY = overflowY === "scroll" || + ( overflowY === "auto" && within.height < within.element[0].scrollHeight ); + return { + width: hasOverflowY ? $.position.scrollbarWidth() : 0, + height: hasOverflowX ? $.position.scrollbarWidth() : 0 + }; + }, + getWithinInfo: function( element ) { + var withinElement = $( element || window ), + isWindow = $.isWindow( withinElement[0] ), + isDocument = !!withinElement[ 0 ] && withinElement[ 0 ].nodeType === 9; + return { + element: withinElement, + isWindow: isWindow, + isDocument: isDocument, + offset: withinElement.offset() || { left: 0, top: 0 }, + scrollLeft: withinElement.scrollLeft(), + scrollTop: withinElement.scrollTop(), + + // support: jQuery 1.6.x + // jQuery 1.6 doesn't support .outerWidth/Height() on documents or windows + width: isWindow || isDocument ? withinElement.width() : withinElement.outerWidth(), + height: isWindow || isDocument ? withinElement.height() : withinElement.outerHeight() + }; + } +}; + +$.fn.position = function( options ) { + if ( !options || !options.of ) { + return _position.apply( this, arguments ); + } + + // make a copy, we don't want to modify arguments + options = $.extend( {}, options ); + + var atOffset, targetWidth, targetHeight, targetOffset, basePosition, dimensions, + target = $( options.of ), + within = $.position.getWithinInfo( options.within ), + scrollInfo = $.position.getScrollInfo( within ), + collision = ( options.collision || "flip" ).split( " " ), + offsets = {}; + + dimensions = getDimensions( target ); + if ( target[0].preventDefault ) { + // force left top to allow flipping + options.at = "left top"; + } + targetWidth = dimensions.width; + targetHeight = dimensions.height; + targetOffset = dimensions.offset; + // clone to reuse original targetOffset later + basePosition = $.extend( {}, targetOffset ); + + // force my and at to have valid horizontal and vertical positions + // if a value is missing or invalid, it will be converted to center + $.each( [ "my", "at" ], function() { + var pos = ( options[ this ] || "" ).split( " " ), + horizontalOffset, + verticalOffset; + + if ( pos.length === 1) { + pos = rhorizontal.test( pos[ 0 ] ) ? + pos.concat( [ "center" ] ) : + rvertical.test( pos[ 0 ] ) ? + [ "center" ].concat( pos ) : + [ "center", "center" ]; + } + pos[ 0 ] = rhorizontal.test( pos[ 0 ] ) ? pos[ 0 ] : "center"; + pos[ 1 ] = rvertical.test( pos[ 1 ] ) ? pos[ 1 ] : "center"; + + // calculate offsets + horizontalOffset = roffset.exec( pos[ 0 ] ); + verticalOffset = roffset.exec( pos[ 1 ] ); + offsets[ this ] = [ + horizontalOffset ? horizontalOffset[ 0 ] : 0, + verticalOffset ? verticalOffset[ 0 ] : 0 + ]; + + // reduce to just the positions without the offsets + options[ this ] = [ + rposition.exec( pos[ 0 ] )[ 0 ], + rposition.exec( pos[ 1 ] )[ 0 ] + ]; + }); + + // normalize collision option + if ( collision.length === 1 ) { + collision[ 1 ] = collision[ 0 ]; + } + + if ( options.at[ 0 ] === "right" ) { + basePosition.left += targetWidth; + } else if ( options.at[ 0 ] === "center" ) { + basePosition.left += targetWidth / 2; + } + + if ( options.at[ 1 ] === "bottom" ) { + basePosition.top += targetHeight; + } else if ( options.at[ 1 ] === "center" ) { + basePosition.top += targetHeight / 2; + } + + atOffset = getOffsets( offsets.at, targetWidth, targetHeight ); + basePosition.left += atOffset[ 0 ]; + basePosition.top += atOffset[ 1 ]; + + return this.each(function() { + var collisionPosition, using, + elem = $( this ), + elemWidth = elem.outerWidth(), + elemHeight = elem.outerHeight(), + marginLeft = parseCss( this, "marginLeft" ), + marginTop = parseCss( this, "marginTop" ), + collisionWidth = elemWidth + marginLeft + parseCss( this, "marginRight" ) + scrollInfo.width, + collisionHeight = elemHeight + marginTop + parseCss( this, "marginBottom" ) + scrollInfo.height, + position = $.extend( {}, basePosition ), + myOffset = getOffsets( offsets.my, elem.outerWidth(), elem.outerHeight() ); + + if ( options.my[ 0 ] === "right" ) { + position.left -= elemWidth; + } else if ( options.my[ 0 ] === "center" ) { + position.left -= elemWidth / 2; + } + + if ( options.my[ 1 ] === "bottom" ) { + position.top -= elemHeight; + } else if ( options.my[ 1 ] === "center" ) { + position.top -= elemHeight / 2; + } + + position.left += myOffset[ 0 ]; + position.top += myOffset[ 1 ]; + + // if the browser doesn't support fractions, then round for consistent results + if ( !supportsOffsetFractions ) { + position.left = round( position.left ); + position.top = round( position.top ); + } + + collisionPosition = { + marginLeft: marginLeft, + marginTop: marginTop + }; + + $.each( [ "left", "top" ], function( i, dir ) { + if ( $.ui.position[ collision[ i ] ] ) { + $.ui.position[ collision[ i ] ][ dir ]( position, { + targetWidth: targetWidth, + targetHeight: targetHeight, + elemWidth: elemWidth, + elemHeight: elemHeight, + collisionPosition: collisionPosition, + collisionWidth: collisionWidth, + collisionHeight: collisionHeight, + offset: [ atOffset[ 0 ] + myOffset[ 0 ], atOffset [ 1 ] + myOffset[ 1 ] ], + my: options.my, + at: options.at, + within: within, + elem: elem + }); + } + }); + + if ( options.using ) { + // adds feedback as second argument to using callback, if present + using = function( props ) { + var left = targetOffset.left - position.left, + right = left + targetWidth - elemWidth, + top = targetOffset.top - position.top, + bottom = top + targetHeight - elemHeight, + feedback = { + target: { + element: target, + left: targetOffset.left, + top: targetOffset.top, + width: targetWidth, + height: targetHeight + }, + element: { + element: elem, + left: position.left, + top: position.top, + width: elemWidth, + height: elemHeight + }, + horizontal: right < 0 ? "left" : left > 0 ? "right" : "center", + vertical: bottom < 0 ? "top" : top > 0 ? "bottom" : "middle" + }; + if ( targetWidth < elemWidth && abs( left + right ) < targetWidth ) { + feedback.horizontal = "center"; + } + if ( targetHeight < elemHeight && abs( top + bottom ) < targetHeight ) { + feedback.vertical = "middle"; + } + if ( max( abs( left ), abs( right ) ) > max( abs( top ), abs( bottom ) ) ) { + feedback.important = "horizontal"; + } else { + feedback.important = "vertical"; + } + options.using.call( this, props, feedback ); + }; + } + + elem.offset( $.extend( position, { using: using } ) ); + }); +}; + +$.ui.position = { + fit: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollLeft : within.offset.left, + outerWidth = within.width, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = withinOffset - collisionPosLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - withinOffset, + newOverRight; + + // element is wider than within + if ( data.collisionWidth > outerWidth ) { + // element is initially over the left side of within + if ( overLeft > 0 && overRight <= 0 ) { + newOverRight = position.left + overLeft + data.collisionWidth - outerWidth - withinOffset; + position.left += overLeft - newOverRight; + // element is initially over right side of within + } else if ( overRight > 0 && overLeft <= 0 ) { + position.left = withinOffset; + // element is initially over both left and right sides of within + } else { + if ( overLeft > overRight ) { + position.left = withinOffset + outerWidth - data.collisionWidth; + } else { + position.left = withinOffset; + } + } + // too far left -> align with left edge + } else if ( overLeft > 0 ) { + position.left += overLeft; + // too far right -> align with right edge + } else if ( overRight > 0 ) { + position.left -= overRight; + // adjust based on position and margin + } else { + position.left = max( position.left - collisionPosLeft, position.left ); + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollTop : within.offset.top, + outerHeight = data.within.height, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = withinOffset - collisionPosTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - withinOffset, + newOverBottom; + + // element is taller than within + if ( data.collisionHeight > outerHeight ) { + // element is initially over the top of within + if ( overTop > 0 && overBottom <= 0 ) { + newOverBottom = position.top + overTop + data.collisionHeight - outerHeight - withinOffset; + position.top += overTop - newOverBottom; + // element is initially over bottom of within + } else if ( overBottom > 0 && overTop <= 0 ) { + position.top = withinOffset; + // element is initially over both top and bottom of within + } else { + if ( overTop > overBottom ) { + position.top = withinOffset + outerHeight - data.collisionHeight; + } else { + position.top = withinOffset; + } + } + // too far up -> align with top + } else if ( overTop > 0 ) { + position.top += overTop; + // too far down -> align with bottom edge + } else if ( overBottom > 0 ) { + position.top -= overBottom; + // adjust based on position and margin + } else { + position.top = max( position.top - collisionPosTop, position.top ); + } + } + }, + flip: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.offset.left + within.scrollLeft, + outerWidth = within.width, + offsetLeft = within.isWindow ? within.scrollLeft : within.offset.left, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = collisionPosLeft - offsetLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - offsetLeft, + myOffset = data.my[ 0 ] === "left" ? + -data.elemWidth : + data.my[ 0 ] === "right" ? + data.elemWidth : + 0, + atOffset = data.at[ 0 ] === "left" ? + data.targetWidth : + data.at[ 0 ] === "right" ? + -data.targetWidth : + 0, + offset = -2 * data.offset[ 0 ], + newOverRight, + newOverLeft; + + if ( overLeft < 0 ) { + newOverRight = position.left + myOffset + atOffset + offset + data.collisionWidth - outerWidth - withinOffset; + if ( newOverRight < 0 || newOverRight < abs( overLeft ) ) { + position.left += myOffset + atOffset + offset; + } + } else if ( overRight > 0 ) { + newOverLeft = position.left - data.collisionPosition.marginLeft + myOffset + atOffset + offset - offsetLeft; + if ( newOverLeft > 0 || abs( newOverLeft ) < overRight ) { + position.left += myOffset + atOffset + offset; + } + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.offset.top + within.scrollTop, + outerHeight = within.height, + offsetTop = within.isWindow ? within.scrollTop : within.offset.top, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = collisionPosTop - offsetTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - offsetTop, + top = data.my[ 1 ] === "top", + myOffset = top ? + -data.elemHeight : + data.my[ 1 ] === "bottom" ? + data.elemHeight : + 0, + atOffset = data.at[ 1 ] === "top" ? + data.targetHeight : + data.at[ 1 ] === "bottom" ? + -data.targetHeight : + 0, + offset = -2 * data.offset[ 1 ], + newOverTop, + newOverBottom; + if ( overTop < 0 ) { + newOverBottom = position.top + myOffset + atOffset + offset + data.collisionHeight - outerHeight - withinOffset; + if ( ( position.top + myOffset + atOffset + offset) > overTop && ( newOverBottom < 0 || newOverBottom < abs( overTop ) ) ) { + position.top += myOffset + atOffset + offset; + } + } else if ( overBottom > 0 ) { + newOverTop = position.top - data.collisionPosition.marginTop + myOffset + atOffset + offset - offsetTop; + if ( ( position.top + myOffset + atOffset + offset) > overBottom && ( newOverTop > 0 || abs( newOverTop ) < overBottom ) ) { + position.top += myOffset + atOffset + offset; + } + } + } + }, + flipfit: { + left: function() { + $.ui.position.flip.left.apply( this, arguments ); + $.ui.position.fit.left.apply( this, arguments ); + }, + top: function() { + $.ui.position.flip.top.apply( this, arguments ); + $.ui.position.fit.top.apply( this, arguments ); + } + } +}; + +// fraction support test +(function() { + var testElement, testElementParent, testElementStyle, offsetLeft, i, + body = document.getElementsByTagName( "body" )[ 0 ], + div = document.createElement( "div" ); + + //Create a "fake body" for testing based on method used in jQuery.support + testElement = document.createElement( body ? "div" : "body" ); + testElementStyle = { + visibility: "hidden", + width: 0, + height: 0, + border: 0, + margin: 0, + background: "none" + }; + if ( body ) { + $.extend( testElementStyle, { + position: "absolute", + left: "-1000px", + top: "-1000px" + }); + } + for ( i in testElementStyle ) { + testElement.style[ i ] = testElementStyle[ i ]; + } + testElement.appendChild( div ); + testElementParent = body || document.documentElement; + testElementParent.insertBefore( testElement, testElementParent.firstChild ); + + div.style.cssText = "position: absolute; left: 10.7432222px;"; + + offsetLeft = $( div ).offset().left; + supportsOffsetFractions = offsetLeft > 10 && offsetLeft < 11; + + testElement.innerHTML = ""; + testElementParent.removeChild( testElement ); +})(); + +})(); + +var position = $.ui.position; + + +/*! + * jQuery UI Draggable 1.11.2 + * http://jqueryui.com + * + * Copyright 2014 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/draggable/ + */ + + +$.widget("ui.draggable", $.ui.mouse, { + version: "1.11.2", + widgetEventPrefix: "drag", + options: { + addClasses: true, + appendTo: "parent", + axis: false, + connectToSortable: false, + containment: false, + cursor: "auto", + cursorAt: false, + grid: false, + handle: false, + helper: "original", + iframeFix: false, + opacity: false, + refreshPositions: false, + revert: false, + revertDuration: 500, + scope: "default", + scroll: true, + scrollSensitivity: 20, + scrollSpeed: 20, + snap: false, + snapMode: "both", + snapTolerance: 20, + stack: false, + zIndex: false, + + // callbacks + drag: null, + start: null, + stop: null + }, + _create: function() { + + if ( this.options.helper === "original" ) { + this._setPositionRelative(); + } + if (this.options.addClasses){ + this.element.addClass("ui-draggable"); + } + if (this.options.disabled){ + this.element.addClass("ui-draggable-disabled"); + } + this._setHandleClassName(); + + this._mouseInit(); + }, + + _setOption: function( key, value ) { + this._super( key, value ); + if ( key === "handle" ) { + this._removeHandleClassName(); + this._setHandleClassName(); + } + }, + + _destroy: function() { + if ( ( this.helper || this.element ).is( ".ui-draggable-dragging" ) ) { + this.destroyOnClear = true; + return; + } + this.element.removeClass( "ui-draggable ui-draggable-dragging ui-draggable-disabled" ); + this._removeHandleClassName(); + this._mouseDestroy(); + }, + + _mouseCapture: function(event) { + var o = this.options; + + this._blurActiveElement( event ); + + // among others, prevent a drag on a resizable-handle + if (this.helper || o.disabled || $(event.target).closest(".ui-resizable-handle").length > 0) { + return false; + } + + //Quit if we're not on a valid handle + this.handle = this._getHandle(event); + if (!this.handle) { + return false; + } + + this._blockFrames( o.iframeFix === true ? "iframe" : o.iframeFix ); + + return true; + + }, + + _blockFrames: function( selector ) { + this.iframeBlocks = this.document.find( selector ).map(function() { + var iframe = $( this ); + + return $( "
    " ) + .css( "position", "absolute" ) + .appendTo( iframe.parent() ) + .outerWidth( iframe.outerWidth() ) + .outerHeight( iframe.outerHeight() ) + .offset( iframe.offset() )[ 0 ]; + }); + }, + + _unblockFrames: function() { + if ( this.iframeBlocks ) { + this.iframeBlocks.remove(); + delete this.iframeBlocks; + } + }, + + _blurActiveElement: function( event ) { + var document = this.document[ 0 ]; + + // Only need to blur if the event occurred on the draggable itself, see #10527 + if ( !this.handleElement.is( event.target ) ) { + return; + } + + // support: IE9 + // IE9 throws an "Unspecified error" accessing document.activeElement from an