Banking
The Banking module imports bank and card statements, categorizes their transactions with rules and by hand, and transfers them to the ledger. It replaces the legacy bank rules, matching engine, categorization workbench, saved sessions, transfer, duplicate resolution and transferred sessions (BANK-04 to BANK-11). The Bank feeds feature brings the transactions of banks connected through Plaid (BANK-01 to BANK-03, rebuilt server-side). Checks reconciliation (BANK-12) and the missing-information export (BANK-07) follow.
| Projects | CrestApps.Almohaseb.Banking (module), CrestApps.Almohaseb.Banking.Core (models, CSV, rules engine, posting rule, service) |
| Feature | CrestApps.Almohaseb.Banking (Banking), enabled by both business recipes; depends on Accounting, Purchases & payables and Sales & receivables |
| Collection | Banking |
Data
| Type | Kind | Holds |
|---|---|---|
AlmohasebBankAccount | Content type (AlmohasebBankAccountPart), so a business can add fields | Name, LedgerAccountId (a non-system asset, or liability for a card), LastFour, IsActive |
BankTransaction | Document | BankAccountId, BatchId, Date, Description, Amount (+ received, − spent), CheckNumber, ExternalId, Status (New, Categorized, Excluded, Transferred), Target (account:{id} or category:{id}), VendorId / CustomerId, Memo, RuleId (null when a person chose), Revision, TransferredUtc |
BankImportBatch | Document | Name, BankAccountId, Source (csv), FileName, FirstDate, LastDate, Imported, Skipped, ImportedUtc, ImportedBy (replaces the legacy saved sessions) |
BankRule | Document | Name, Priority, Match (ContainsWords, Exact, StartsWith), Text, Direction, BankAccountId, MinAmount, MaxAmount, Exclude, Target, VendorId, CustomerId, Memo, ApplyOnce, IsActive, CreatedUtc |
BankingMigrations (version 1) creates the type and BankTransactionIndex (BankAccountId, Date; BankAccountId, ExternalId; BatchId, Status; ItemId), BankImportBatchIndex and BankRuleIndex.
CSV import
CsvReader.Parse(Imports,Imports.Core) reads RFC 4180 CSV (quoted fields, doubled quotes, line breaks in quotes, BOM), with the separator (comma, semicolon or tab) detected on the first line.BankCsvMappingGuesser.Guess(headers)maps date, description, amount or debit/credit, and check number from the header names.BankCsvImporter.Read(rows, mapping)returnsBankLines andBankLineErrors (MissingColumns,BadDate,BadAmount,NoDescription). Amounts accept$, thousands separators,(12.00)and12.00-; a signed column followsPositiveIsSpent, debit/credit columns become received − spent; zero lines are dropped; dates use the mapping's format or US and ISO formats.GetExternalIds(account, lines)hashes the account, date, amount, description (upper case, spaces collapsed) and the occurrence of identical lines within the file.ImportAsyncskips ids already in the account, so overlapping statements and re-imports add nothing, while equal lines in one file stay distinct. The legacy sessions stored no provider or account id and could not dedupe (BANK-08).
Rules engine
BankRuleMatcher is pure:
Tokenizesplits text into upper-case words of letters and digits. Matching is on whole words, case-insensitive (the legacy matched substrings, case-sensitive).IsMatchchecks the rule is active, the direction, the account, the absolute amount range, then the text byMatch.Chooseorders matches byPriority, then specificity (Exact,StartsWith,ContainsWords), then word count (descending), thenCreatedUtc, then id. An FsCheck property checks the winner does not depend on the order of the rules (the legacy took the alphabetically last title, BANK-05).SuggestTextreturns the non-numeric words every selected description shares (the legacyExtractMutualWords).
ApplyRulesAsync and imports apply rules only to New transactions and to those a rule categorized (RuleId set); a person's choice is never overridden. An ApplyOnce rule is used for one transaction per run and then turned off (the legacy never cleaned these up after a Plaid import).
Review, transfer and undo
IBankingService / BankingService:
CategorizeAsync(id, target, party, memo)validates the target: an active account that is not the bank's own (TargetIsBank), a purchase category for money spent or a sales category for money received (TargetWrongDirection). Anulltarget sets it back toNew.ExcludeAsynctogglesExcluded.TransferAsync(ids, keepBoth)posts each categorized transaction. Money spent is first checked byFindDuplicatesAsync: any bill or vendor payment (PayableIndex) with the same total within ±14 days (DuplicateWindowDays, the legacy heuristic, now including payments). A match returnsPossibleDuplicatewith the entries unless the id is inkeepBoth. Outcomes:Transferred,PossibleDuplicate,NotCategorized,Rejected(posting errors, such as a closed period).UndoTransferAsyncposts the transaction as not transferred, which reverses its entry, and sets it back toCategorized. The legacy undo used three transactions and could leave a partial undo (BANK-11).DeleteBatchAsyncdeletes a batch and its transactions, refused while any is transferred.
BankTransactionPostingRule (Banking.Transaction) posts only Transferred transactions: money spent Dr the target, Cr the bank's ledger account; money received the reverse. A category target resolves to its account, or to the Purchases or Sales system account. The legacy transfer wrote source documents only, by direct table writes, with no ledger (BANK-09).
Admin
The Banking menu (almohasebBanking, icon fa-building-columns): Review transactions, Import a statement, Imports, Bank rules and Bank accounts (the content list).
| Route (under the admin prefix) | Route name | Methods | Permission |
|---|---|---|---|
Almohaseb/Banking | AlmohasebBankReview, AlmohasebBankReviewPost | GET (bankAccountId, batchId, status, from, to, search; at most 200 rows), POST (Rows[i], command = save, rules, exclude, include, transfer, transferAnyway, undo, rule) | View; Manage to post |
Almohaseb/Banking/Import (+ /Upload, /Confirm) | AlmohasebBankImport, ... | GET, POST multipart (5 MB), POST (preview) | Manage |
Almohaseb/Banking/Imports (+ /Delete/{id}) | AlmohasebBankBatches, AlmohasebBankBatchDelete | GET, POST | View; Manage to delete |
Almohaseb/Banking/Rules (+ /Create (fromIds), /Edit/{id}, /Save (preview), /Delete/{id}) | AlmohasebBankRules, ... | GET, POST | View; Manage to change |
Every POST of the review form first saves what was entered in the rows. BankingLists builds the pick-lists: purchase or sales categories, then every active account grouped by type.
Bank feeds (Plaid)
Feature: CrestApps.Almohaseb.Banking.Plaid (Bank feeds), enabled by both business recipes; depends on Banking and Jobs. Code: CrestApps.Almohaseb.Banking.Core/Plaid (client, verifier, store, service) and CrestApps.Almohaseb.Banking/Plaid (startup, controller, job, webhook).
The platform has one Plaid account, configured in the host (see Configuration); each business keeps its own connections. Without a client ID and secret, Bank feeds says so and nothing calls Plaid.
Data
BankConnection (Banking collection, BankConnectionIndex on PlaidItemId and Status, stored by BankConnectionStore, a CrestApps DocumentCatalog): the Plaid item id, ProtectedAccessToken (ISensitiveDataProtector, purpose CrestApps.Almohaseb.Banking.PlaidAccessToken, so only the business's own key ring reads it; it is never shown, exported or logged), the bank's id and name, Status (Active, NeedsReconnect, Error), LastError, LastSyncUtc, and Accounts: each Plaid account with its name, mask, kind, BankAccountId (the bank account it feeds, or none), ImportFrom, its own sync Cursor, and the last balances.
BankTransaction gains ChangedAtBank and RemovedAtBank; a feed transaction's ExternalId is plaid:{transaction_id}, so it comes in once.
Flow
- Link.
LinkToken(POST, JSON) asks/link/token/createfor a token: productstransactions,days_requested730, the user's id asclient_user_id, and the webhookhttps://{host}{path base}/almohaseb/plaid/webhookwhen the site is served over https on a host Plaid can reach (none on localhost). With a connection id it is update mode (the access token, no products). The page opens Plaid Link (cdn.plaid.com/link/v2/stable/link-initialize.js). - Connect. Link's public token is posted to
Connect;/item/public_token/exchangegives the access token, protected at once;/accounts/getand/institutions/get_by_idgive the accounts and the bank's name. The same Plaid item connected again keeps its links and cursors. - Link accounts.
Accounts/{connectionId}saves each account's bank account and Bring in from date (LinkAccountsAsync): a bank account is fed by one Plaid account across every connection; a changed link or date clears the account's cursor, and the external ids keep transactions from coming twice. - Sync.
BankFeedSyncJobHandler(job typeCrestApps.Almohaseb.Banking.BankFeedSync, idempotency keybank-feed:{connection id}, batches of 10 pages) callsIBankFeedService.SyncAsync: for each linked account,/transactions/syncwith the account's cursor (options.account_id,include_original_description, 500 a page). Each page goes throughIBankingService.ApplyFeedAsyncand moves the cursor in the same unit of work.TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATIONasks the page again from the cursor applied last. The balances are read again after the sync. - Apply.
ApplyFeedAsync: posted transactions only (a pending one returns under another id when it posts), on or after Bring in from, with Plaid's sign turned (Plaid's amounts are positive when money leaves). New ones are added to a batch (sourcefeed) and the bank rules applied; changed ones are updated while in review, removed ones deleted; a transferred one is flaggedChangedAtBankorRemovedAtBank, and its entry is left alone. - Errors. A Plaid error that needs the person (
ITEM_LOGIN_REQUIRED,PENDING_EXPIRATION, ...) setsNeedsReconnect; any other setsError, and the next sync tries again. Sign in again opens Link in update mode, thenReconnected/{connectionId}reads the accounts and queues a sync.
Webhook
POST {site}/almohaseb/plaid/webhook (minimal API, anonymous, no antiforgery, excluded from the API description; 64 KB at most). PlaidWebhookVerifier checks the Plaid-Verification JWT: ES256, the key of its kid from /webhook_verification_key/get (cached an hour; an expired key is refused), a signature over the header and payload, iat at most five minutes old, and request_body_sha256 equal to the SHA-256 of the body received; anything else is 401. HandleWebhookAsync then queues a sync on TRANSACTIONS SYNC_UPDATES_AVAILABLE (and the older update codes) and ITEM LOGIN_REPAIRED, sets NeedsReconnect on ITEM ERROR with ITEM_LOGIN_REQUIRED, PENDING_EXPIRATION, PENDING_DISCONNECT, USER_PERMISSION_REVOKED and USER_ACCOUNT_REVOKED, and notes NEW_ACCOUNTS_AVAILABLE.
BankFeedBackgroundTask (06:15 each day) queues a sync of every connection with a linked account that does not need a sign-in, in case a webhook was missed.
Removing
Remove/{connectionId} calls /item/remove (an item Plaid no longer knows is logged and removed here anyway) and deletes the connection; its transactions stay.
| Route (under the admin prefix) | Route name | Methods | Permission |
|---|---|---|---|
Almohaseb/Banking/Feeds | AlmohasebBankFeeds | GET | Manage bank connections |
Almohaseb/Banking/Feeds/LinkToken | AlmohasebBankFeedsLinkToken | POST (connectionId), JSON { linkToken } | Manage bank connections |
Almohaseb/Banking/Feeds/Connect | AlmohasebBankFeedsConnect | POST (publicToken) | Manage bank connections |
Almohaseb/Banking/Feeds/Accounts/{connectionId} | AlmohasebBankFeedsAccounts | POST (accounts[i].PlaidAccountId, BankAccountId, ImportFrom) | Manage bank connections |
Almohaseb/Banking/Feeds/Sync/{connectionId}, /Reconnected/{connectionId}, /Remove/{connectionId} | AlmohasebBankFeedsSync, ... | POST | Manage bank connections |
Permissions
| Permission | Key | Implied by |
|---|---|---|
| Import, categorize and transfer bank transactions, and keep the bank rules | ManageAlmohasebBanking | |
| View bank transactions and bank rules | ViewAlmohasebBanking | ManageAlmohasebBanking |
| Connect banks through Plaid and link their accounts to the books | ManageAlmohasebBankConnections (Bank feeds) |
Bank accounts use the content permissions of the AlmohasebBankAccount type.