-
Notifications
You must be signed in to change notification settings - Fork 0
feat: REI Hub general ledger import endpoint #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1069,6 +1069,255 @@ importRoutes.post('/api/import/amazon', async (c) => { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, imported > 0 ? 200 : 422); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // POST /api/import/reihub — import REI Hub general ledger (TurboTenant accounting hub) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Accepts raw CSV or tab-delimited body with columns: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Account, Date, Type, Description, Debit, Credit, Split Account, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Scope, Property, Unit, Sub-Portfolio, Vendor, Fixed Asset, Additional Notes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Groups by Account category (Rent, Repairs, Cleaning, etc.) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Dedup via description hash + date + amount. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| importRoutes.post('/api/import/reihub', async (c) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const storage = c.get('storage'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const body = await c.req.text(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // REI Hub exports as pipe-delimited markdown table or CSV — detect format | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const lines = body.split('\n').filter((l) => l.trim() && !l.trim().startsWith('| :-')); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (lines.length < 2) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return c.json({ error: 'No data rows found' }, 400); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Parse header — handle both pipe-delimited (markdown table) and CSV | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const isPipeDelimited = lines[0].includes('|'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function parseLine(line: string): string[] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isPipeDelimited) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return line.split('|').map((cell) => cell.trim()).filter((_, i, arr) => i > 0 && i < arr.length); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1082
to
+1093
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // REI Hub exports as pipe-delimited markdown table or CSV — detect format | |
| const lines = body.split('\n').filter((l) => l.trim() && !l.trim().startsWith('| :-')); | |
| if (lines.length < 2) { | |
| return c.json({ error: 'No data rows found' }, 400); | |
| } | |
| // Parse header — handle both pipe-delimited (markdown table) and CSV | |
| const isPipeDelimited = lines[0].includes('|'); | |
| function parseLine(line: string): string[] { | |
| if (isPipeDelimited) { | |
| return line.split('|').map((cell) => cell.trim()).filter((_, i, arr) => i > 0 && i < arr.length); | |
| } | |
| // REI Hub exports as pipe-delimited markdown table, tab-delimited text, or CSV — detect format | |
| const lines = body.split('\n').filter((l) => l.trim() && !l.trim().startsWith('| :-')); | |
| if (lines.length < 2) { | |
| return c.json({ error: 'No data rows found' }, 400); | |
| } | |
| // Parse header — handle pipe-delimited (markdown table), tab-delimited text, and CSV | |
| const isPipeDelimited = lines[0].includes('|'); | |
| const isTabDelimited = !isPipeDelimited && lines[0].includes('\t'); | |
| function parseLine(line: string): string[] { | |
| if (isPipeDelimited) { | |
| return line.split('|').map((cell) => cell.trim()).filter((_, i, arr) => i > 0 && i < arr.length); | |
| } | |
| if (isTabDelimited) { | |
| return line.split('\t').map((cell) => cell.trim()); | |
| } |
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ACCOUNT_COA_MAP appears to use COA codes that don't match the repo’s canonical chart/mappings in database/chart-of-accounts.ts (e.g., Rent is 4000 there, Repairs is 5070, Mortgage Interest is 5300, and codes like 6110/6050/6060 are not defined). This will produce systematically incorrect suggestedCoaCode values. Please align these mappings with REI_CHART_OF_ACCOUNTS/TURBOTENANT_CATEGORY_MAP (or validate codes via getAccountByCode before storing) so suggestions are always valid and consistent.
| // REI Hub Account category → COA code mapping | |
| const ACCOUNT_COA_MAP: Record<string, string> = { | |
| 'rent': '4010', // Rental income | |
| 'management fees': '6110', // Management expenses | |
| 'cleaning and maintenance': '5040', // Maintenance | |
| 'repairs': '5030', // Repairs | |
| 'insurance': '5060', // Insurance | |
| 'mortgage interest': '5080', // Mortgage interest | |
| 'property taxes': '5050', // Property taxes | |
| 'utilities': '5070', // Utilities | |
| 'supplies': '5040', // Supplies (under maintenance) | |
| 'travel': '6050', // Travel | |
| 'legal and professional': '6060', // Legal/professional | |
| 'advertising': '6020', // Advertising | |
| 'auto balance': '9010', // Opening balances → suspense | |
| 'owner funds': '3010', // Owner equity | |
| 'depreciation': '5090', // Depreciation | |
| 'capital expenditures': '1500', // Capital improvements | |
| }; | |
| // REI Hub Account category → canonical chart account name mapping. | |
| // Resolve codes via `findAccountCode` so suggested COA codes always come | |
| // from the repo's canonical chart of accounts instead of stale literals. | |
| const ACCOUNT_CATEGORY_TO_CANONICAL_NAME: Record<string, string> = { | |
| 'rent': 'Rent', | |
| 'management fees': 'Management Fees', | |
| 'cleaning and maintenance': 'Cleaning and Maintenance', | |
| 'repairs': 'Repairs', | |
| 'insurance': 'Insurance', | |
| 'mortgage interest': 'Mortgage Interest', | |
| 'property taxes': 'Property Taxes', | |
| 'utilities': 'Utilities', | |
| 'supplies': 'Supplies', | |
| 'travel': 'Travel', | |
| 'legal and professional': 'Legal and Professional', | |
| 'advertising': 'Advertising', | |
| 'auto balance': 'Auto Balance', | |
| 'owner funds': 'Owner Funds', | |
| 'depreciation': 'Depreciation', | |
| 'capital expenditures': 'Capital Expenditures', | |
| }; | |
| const ACCOUNT_COA_MAP: Record<string, string> = Object.fromEntries( | |
| Object.entries(ACCOUNT_CATEGORY_TO_CANONICAL_NAME).flatMap(([category, accountName]) => { | |
| const code = findAccountCode(accountName); | |
| return code ? [[category, code]] : []; | |
| }) | |
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Select a deterministic account for REI Hub rows
The importer picks accounts.find((a) => a.isActive) as the “default” account, but getAccounts is ordered by updatedAt, so this effectively means “most recently updated active account.” For tenants with multiple active accounts, imports can land in different (and incorrect) ledgers over time after unrelated account edits, causing account-level balances and reconciliation to be wrong.
Useful? React with 👍 / 👎.
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unlike other import endpoints in this file, this route doesn't require an explicit target accountId (X-Account-ID or query param) and instead picks the first active account returned by storage.getAccounts(tenantId). That choice depends on account ordering and can silently import into the wrong account when a tenant has multiple active accounts. Consider requiring an account ID (or a deterministic account selection strategy per tenant) and validating it belongs to the resolved tenant.
| const active = accounts.find((a) => a.isActive); | |
| if (active) { | |
| tenantDefaultAccount[tenantId] = active.id; | |
| return active.id; | |
| const activeAccounts = accounts.filter((a) => a.isActive); | |
| if (activeAccounts.length === 1) { | |
| tenantDefaultAccount[tenantId] = activeAccounts[0].id; | |
| return activeAccounts[0].id; |
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveTenantId(property, subPortfolio, scope) takes scope but never uses it. Either incorporate scope into resolution (if it carries needed routing info) or remove the parameter to avoid implying behavior that isn't implemented.
| } | |
| } | |
| // Try scope as an additional routing hint | |
| const scopeLower = scope.toLowerCase(); | |
| for (const [pattern, slug] of Object.entries(PORTFOLIO_TENANT_MAP)) { | |
| if (scopeLower.includes(pattern)) return slugToTenantId[slug] || null; | |
| } |
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This endpoint resolves tenantId per row using storage.getTenants() + hard-coded slug mappings, instead of using the request-scoped c.get('tenantId') / tenantMiddleware membership check. As written, any authenticated caller for one tenant could import transactions into other tenants they don't belong to. Please restrict imports to the current tenant (and treat property/sub-portfolio as metadata only), or explicitly authorize cross-tenant imports (e.g., verify storage.getUserTenants(c.get('userId')) includes the resolved tenantId, or require service-token-only access for multi-tenant writes).
Copilot
AI
Apr 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
externalId uses ${amount} where amount is a JS floating-point value derived from parseFloat. This can introduce unstable IDs due to floating precision (e.g., 10.1 becoming 10.0999999998), breaking deduplication across runs. Consider normalizing to cents (integer) or using a fixed decimal string (e.g., 2dp) for both externalId and stored amount.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce caller tenant boundary for REI Hub imports
This route derives the target tenantId from CSV content (property/subPortfolio) and then writes transactions with that resolved ID, rather than constraining writes to c.get('tenantId'). In practice, a caller who is authorized for one tenant can submit rows that match another tenant mapping and create transactions in that other tenant, which breaks tenant isolation and can corrupt another entity’s ledger.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pipe-delimited parsing has an off-by-one issue:
filter((_, i, arr) => i > 0 && i < arr.length)doesn't drop the trailing empty cell when a line ends with|, and it will drop the first real column if a line doesn't start with a leading|. A safer approach is totrim()then strip a single leading/trailing pipe (if present) before splitting, or to filter withi < arr.length - 1.