Skip to content

feat: REI Hub general ledger import endpoint#107

Merged
chitcommit merged 1 commit intomainfrom
feat/reihub-import
Apr 24, 2026
Merged

feat: REI Hub general ledger import endpoint#107
chitcommit merged 1 commit intomainfrom
feat/reihub-import

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

Summary

  • POST /api/import/reihub — ingests REI Hub (TurboTenant accounting hub) general ledger exports
  • Handles CSV format with 14 columns (Account, Date, Type, Description, Debit, Credit, Split Account, Scope, Property, Unit, Sub-Portfolio, Vendor, Fixed Asset, Additional Notes)
  • Account category → COA code mapping (Rent→4010, Repairs→5030, Cleaning→5040, etc.)
  • Property address + Sub-Portfolio → tenant resolution
  • TurboTenant Payment ID extraction from descriptions for cross-source dedup
  • Per-category import statistics in response

Test plan

  • npx tsc --noEmit passes
  • Deploy and ingest 7.8K row general ledger from REI Hub export
  • Verify category stats show correct distribution across account types

🤖 Generated with Claude Code

POST /api/import/reihub — ingests REI Hub (TurboTenant accounting hub)
general ledger exports. Handles pipe-delimited markdown table format.

- 14-column double-entry ledger (Account, Date, Type, Description,
  Debit, Credit, Split Account, Scope, Property, Unit, Sub-Portfolio,
  Vendor, Fixed Asset, Additional Notes)
- Account category → COA code mapping (Rent→4010, Repairs→5030, etc.)
- Property address + Sub-Portfolio → tenant resolution
- TurboTenant Payment ID extraction from descriptions for dedup
- Per-category import statistics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 24, 2026 12:20
@chitcommit chitcommit enabled auto-merge (squash) April 24, 2026 12:21
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 24, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
chittyfinance 512d191 Apr 24 2026, 12:21 PM

@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

Warning

Rate limit exceeded

@chitcommit has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 49 minutes and 32 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 49 minutes and 32 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aa0a9885-8cbc-4b77-8ff1-ee2cedd21cad

📥 Commits

Reviewing files that changed from the base of the PR and between fdd22bd and 512d191.

📒 Files selected for processing (1)
  • server/routes/import.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/reihub-import

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chitcommit chitcommit merged commit f8f1e52 into main Apr 24, 2026
12 of 15 checks passed
@chitcommit chitcommit deleted the feat/reihub-import branch April 24, 2026 12:22
@claude
Copy link
Copy Markdown

claude Bot commented Apr 24, 2026

Code Review: REI Hub General Ledger Import

Overall: Solid addition that follows the established import pattern in import.ts. The format auto-detection (pipe-delimited vs CSV) and the stateful category-header parsing are both well-reasoned for REI Hub's unusual export format. A few issues worth addressing before merge.


Bugs / Correctness

1. scope parameter is silently ignored
resolveTenantId(property, subPortfolio, scope) accepts scope as a third argument, but the function body never uses it. If REI Hub sets a meaningful scope field, this fallback chain could route transactions to the wrong tenant with no warning.

2. Silent default fallback is too aggressive

return slugToTenantId['aribia-mgmt'] || null;

Any row that doesn't match a property address or sub-portfolio silently falls under aribia-mgmt. For a 7.8K-row ledger spanning multiple entities, this will silently misclassify anything with an unrecognized property string (typos, new units, international addresses). Consider returning null and incrementing skipped/errors instead — same as TurboTenant import does.

3. amount sign is computed then discarded

const amount = isIncome ? credit : -debit;
// ...
amount: String(Math.abs(amount)),

The sign on amount is never used — Math.abs is called immediately. The negative-for-expenses convention set up above is misleading. Either remove the negation when computing amount, or use the sign in the stored value (and drop the redundant type field as the source of truth).

4. Rows with both debit and credit non-zero are not handled
const isIncome = credit > 0 && debit === 0 — if a row has values in both columns (split transaction, partial payment), it falls into the expense branch using the debit amount and silently drops the credit. REI Hub split transactions can have both. Worth logging a warning and skipping or using credit - debit net.


Performance

5. No bulk dedup or batch insert — 7.8K sequential round-trips
Each row does getTransactionByExternalId + conditionally createTransaction, both as individual awaited queries. For the cited 7.8K-row ledger this is ~15K sequential DB calls. The Amazon and Mercury importers have the same issue, but given this is the largest dataset mentioned, it's worth addressing. A pre-fetch of existing external IDs for the affected date range + a bulk insert would be substantially faster.


Security / Auth

6. No explicit auth check in the handler
Other import endpoints in this file rely on hybridAuth middleware wired at the router level (confirmed by server/app.ts). This endpoint inherits that — but since there's no tenant-scoping via a URL param or X-Account-ID header like TurboTenant import uses, any authenticated caller gets to write to tenants determined by hardcoded maps. Consider at least validating the caller has access to the resolved tenants before inserting.


Hardcoded Entity Data

7. PROPERTY_TENANT_MAP / PORTFOLIO_TENANT_MAP are IT CAN BE LLC-specific
These maps hardcode specific Chicago addresses and entity slugs. This is consistent with how other importers in this file work (TurboTenant, HD Pro), but it means the endpoint is not reusable for any other tenant configuration. A comment noting this intentional coupling would clarify the design — or a future follow-up could make these maps database-driven (stored alongside the tenant record).

8. supplies and cleaning and maintenance both map to 5040
This is probably intentional (both are maintenance), but worth a comment. REI Hub treats them as distinct account categories, so if a future audit splits them, the mapping will need updating.


Minor

  • iSplitAccount, iFixedAsset, iNotes are extracted but only stored in metadata. That's fine — just confirming this is deliberate (no COA logic for fixed assets yet).
  • The parseLine and getDefaultAccount functions defined inside the handler closure are fine for request scope, but parseLine could be hoisted to module scope since it has no closure dependencies other than isPipeDelimited — which is itself a constant per request. Not a bug, just slightly harder to test in isolation.
  • findAccountCode(description) is called without the second department argument. This is acceptable if the parameter is optional, but passing effectiveCategory as the department hint would likely improve classification quality for the fallback path.

Test Coverage

The test plan has the automated deploy + data checks unchecked. Given the hardcoded tenant maps and the category-header state machine, a unit test covering:

  1. Pipe-delimited vs CSV format detection
  2. Category header row detection (account populated, date/type empty)
  3. resolveTenantId for each known property pattern
  4. The dedup path (same externalId twice)

...would significantly reduce regression risk on the parsing logic before the 7.8K-row live test.


Summary: Items 1–4 are worth fixing before merge. Items 5–6 are acceptable tech debt with a note. The rest are minor. Good work on the format auto-detection and TurboTenant Payment ID cross-source dedup — those are non-obvious and well-executed.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 512d191b7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server/routes/import.ts
Comment on lines +1257 to +1258
const tenantId = resolveTenantId(property, subPortfolio, scope);
if (!tenantId) { skipped++; continue; }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread server/routes/import.ts
Comment on lines +1164 to +1166
const accounts = await storage.getAccounts(tenantId);
const active = accounts.find((a) => a.isActive);
if (active) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new protected import route for ingesting REI Hub (TurboTenant accounting hub) general-ledger exports into the transactions system, with deduplication and per-category import statistics.

Changes:

  • Adds POST /api/import/reihub to parse REI Hub exports (CSV or pipe-delimited table) and create transactions.
  • Implements tenant resolution from property/sub-portfolio heuristics and TurboTenant Payment ID extraction for dedup.
  • Returns per-category imported/skipped stats and writes an audit entry via ledgerLog.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/routes/import.ts
const portfolioLower = subPortfolio.toLowerCase();
for (const [pattern, slug] of Object.entries(PORTFOLIO_TENANT_MAP)) {
if (portfolioLower.includes(pattern)) return slugToTenantId[slug] || null;
}
Copy link

Copilot AI Apr 24, 2026

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.

Suggested change
}
}
// 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 uses AI. Check for mistakes.
Comment thread server/routes/import.ts
Comment on lines +1156 to +1186
const allTenants = await storage.getTenants();
const slugToTenantId: Record<string, string> = {};
for (const t of allTenants) slugToTenantId[t.slug] = t.id;

// Get a default account per tenant for transaction storage
const tenantDefaultAccount: Record<string, string> = {};
async function getDefaultAccount(tenantId: string): Promise<string | null> {
if (tenantDefaultAccount[tenantId]) return tenantDefaultAccount[tenantId];
const accounts = await storage.getAccounts(tenantId);
const active = accounts.find((a) => a.isActive);
if (active) {
tenantDefaultAccount[tenantId] = active.id;
return active.id;
}
return null;
}

function resolveTenantId(property: string, subPortfolio: string, scope: string): string | null {
// Try property address first
const propLower = property.toLowerCase();
for (const [pattern, slug] of Object.entries(PROPERTY_TENANT_MAP)) {
if (propLower.includes(pattern)) return slugToTenantId[slug] || null;
}
// Try sub-portfolio
const portfolioLower = subPortfolio.toLowerCase();
for (const [pattern, slug] of Object.entries(PORTFOLIO_TENANT_MAP)) {
if (portfolioLower.includes(pattern)) return slugToTenantId[slug] || null;
}
// Default to ARIBIA MGMT
return slugToTenantId['aribia-mgmt'] || null;
}
Copy link

Copilot AI Apr 24, 2026

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 uses AI. Check for mistakes.
Comment thread server/routes/import.ts
Comment on lines +1119 to +1138
// 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
};

Copy link

Copilot AI Apr 24, 2026

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.

Suggested change
// 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]] : [];
})
);

Copilot uses AI. Check for mistakes.
Comment thread server/routes/import.ts
Comment on lines +1165 to +1168
const active = accounts.find((a) => a.isActive);
if (active) {
tenantDefaultAccount[tenantId] = active.id;
return active.id;
Copy link

Copilot AI Apr 24, 2026

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.

Suggested change
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 uses AI. Check for mistakes.
Comment thread server/routes/import.ts
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);
Copy link

Copilot AI Apr 24, 2026

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 to trim() then strip a single leading/trailing pipe (if present) before splitting, or to filter with i < arr.length - 1.

Suggested change
return line.split('|').map((cell) => cell.trim()).filter((_, i, arr) => i > 0 && i < arr.length);
const trimmedLine = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return trimmedLine.split('|').map((cell) => cell.trim());

Copilot uses AI. Check for mistakes.
Comment thread server/routes/import.ts
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);
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route header comment says it accepts "tab-delimited" input, but the implementation only supports pipe-delimited markdown (|) or comma-delimited CSV via parseCSVLine (no tab parsing). Please either add tab-delimited parsing support or update the comment so the documented contract matches behavior.

Suggested change
// 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 uses AI. Check for mistakes.
Comment thread server/routes/import.ts
Comment on lines +1224 to +1255
const debit = parseFloat(debitStr.replace(/[$,]/g, '')) || 0;
const credit = parseFloat(creditStr.replace(/[$,]/g, '')) || 0;
if (debit === 0 && credit === 0) { skipped++; continue; }

// Net amount: credits are income (positive), debits are expenses (negative for P&L)
const isIncome = credit > 0 && debit === 0;
const amount = isIncome ? credit : -debit;

// Parse date (YYYY-MM-DD or MM/DD/YYYY)
let date: Date;
if (dateStr.includes('-') && dateStr.length === 10) {
date = new Date(dateStr);
} else {
const parts = dateStr.split('/');
if (parts.length === 3) {
date = new Date(`${parts[2]}-${parts[0].padStart(2, '0')}-${parts[1].padStart(2, '0')}`);
} else {
date = new Date(dateStr);
}
}
if (isNaN(date.getTime())) { skipped++; continue; }

// Extract TurboTenant Payment ID from description if present
const paymentMatch = description.match(/PAYMENT\s*#(\d+)/i);
const ttPaymentId = paymentMatch ? paymentMatch[1] : null;

// Build external ID for dedup
const descHash = description.slice(0, 40).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
const externalId = ttPaymentId
? `reihub:tt:${ttPaymentId}`
: `reihub:${dateStr}:${amount}:${descHash}`;

Copy link

Copilot AI Apr 24, 2026

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants