Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 344 additions & 0 deletions server/routes/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,350 @@ importRoutes.post('/api/import/amazon', async (c) => {
}, imported > 0 ? 200 : 422);
});

// POST /api/import/turbotenant-deposits — import TurboTenant deposit/payment history
// Accepts raw CSV body with columns: Payment ID, Deposit Amount, Date Deposited,
// Tenant, Payment Method, Lease Address, Payment note, Payment Date Paid, Bank Account, Lease
// Auto-resolves property→tenant and bank account→account from CSV data.
// Deduplicates via TurboTenant Payment ID.
importRoutes.post('/api/import/turbotenant-deposits', async (c) => {
const storage = c.get('storage');

const body = await c.req.text();
const lines = body.split('\n').filter((l) => l.trim());
if (lines.length < 2) {
return c.json({ error: 'CSV must have header + data rows' }, 400);
}

const cols = parseCSVLine(lines[0]);
Comment on lines +1081 to +1086
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.

CSV header parsing doesn’t strip a UTF-8 BOM. If the export includes a BOM (common with Excel/Google Drive), colIdx('Payment ID') can fail and the route will incorrectly report missing required columns. Consider normalizing line endings and stripping a leading \uFEFF from the header line before calling parseCSVLine.

Suggested change
const lines = body.split('\n').filter((l) => l.trim());
if (lines.length < 2) {
return c.json({ error: 'CSV must have header + data rows' }, 400);
}
const cols = parseCSVLine(lines[0]);
const normalizedBody = body.replace(/\r\n?/g, '\n');
const lines = normalizedBody.split('\n').filter((l) => l.trim());
if (lines.length < 2) {
return c.json({ error: 'CSV must have header + data rows' }, 400);
}
const headerLine = lines[0].replace(/^\uFEFF/, '');
const cols = parseCSVLine(headerLine);

Copilot uses AI. Check for mistakes.
const colIdx = (name: string) => cols.findIndex((col) => col.trim().toLowerCase() === name.toLowerCase());

const iPaymentId = colIdx('Payment ID');
const iAmount = colIdx('Deposit Amount');
const iDateDeposited = colIdx('Date Deposited');
const iTenant = colIdx('Tenant');
const iPaymentMethod = colIdx('Payment Method');
const iAddress = colIdx('Lease Address');
const iNote = colIdx('Payment note');
const iDatePaid = colIdx('Payment Date Paid');
const iBankAccount = colIdx('Bank Account');
const iLease = colIdx('Lease');

if (iPaymentId < 0 || iAmount < 0 || iDateDeposited < 0) {
return c.json({ error: 'Missing required columns: Payment ID, Deposit Amount, Date Deposited' }, 400);
}

// Address → tenant mapping
const ADDRESS_TENANT_MAP: Record<string, string> = {
'541 w addison': 'nicholas-bianchi',
'addison': 'nicholas-bianchi',
'550 w surf st c504': 'nicholas-bianchi',
'surf st c504': 'nicholas-bianchi',
'surf c504': 'nicholas-bianchi',
'550 w surf st c211': 'aribia-city-studio',
'surf st c211': 'aribia-city-studio',
'surf c211': 'aribia-city-studio',
'carrera 76': 'aribia-llc',
'colombia': 'aribia-llc',
'medellin': 'aribia-llc',
};

// Bank account pattern → Mercury account name prefix for lookup
const BANK_ACCOUNT_MAP: Record<string, { tenantSlug: string; namePattern: string }> = {
'cozy (2955)': { tenantSlug: 'nicholas-bianchi', namePattern: 'Cozy' },
'cozy': { tenantSlug: 'nicholas-bianchi', namePattern: 'Cozy' },
'loft (5890)': { tenantSlug: 'nicholas-bianchi', namePattern: 'Loft' },
'loft': { tenantSlug: 'nicholas-bianchi', namePattern: 'Loft' },
'city (3372)': { tenantSlug: 'aribia-city-studio', namePattern: 'City' },
'city': { tenantSlug: 'aribia-city-studio', namePattern: 'City' },
'fee (8130)': { tenantSlug: 'aribia-mgmt', namePattern: 'Fee' },
'surf 504 rental': { tenantSlug: 'nicholas-bianchi', namePattern: 'Cozy' },
'surf 211 rental': { tenantSlug: 'aribia-city-studio', namePattern: 'City' },
'addison 3s rental': { tenantSlug: 'nicholas-bianchi', namePattern: 'Loft' },
'huntington': { tenantSlug: 'aribia-mgmt', namePattern: 'MGMT' },
'city deposit': { tenantSlug: 'aribia-city-studio', namePattern: 'City' },
'huntington cozy': { tenantSlug: 'nicholas-bianchi', namePattern: 'Cozy' },
'huntington rental': { tenantSlug: 'aribia-mgmt', namePattern: 'MGMT' },
};

// Load tenants and accounts
const allTenants = await storage.getTenants();
const slugToTenantId: Record<string, string> = {};
for (const t of allTenants) slugToTenantId[t.slug] = t.id;

Comment on lines +1137 to +1141
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 ignores the caller-scoped tenantId from tenantMiddleware and instead loads all active tenants (storage.getTenants()) and writes transactions into whatever tenantId is inferred from the CSV. That bypasses the membership check in tenantMiddleware and allows a caller with access to a single tenant to create transactions in other tenants. Restrict imports to c.get('tenantId') (and only resolve accounts/properties within that tenant), or explicitly verify the caller is a member of any resolved tenantId (e.g., via storage.getUserTenants(c.get('userId'))) before reading accounts/creating transactions.

Copilot uses AI. Check for mistakes.
// Cache: tenantId → accounts list
const accountsCache: Record<string, Awaited<ReturnType<typeof storage.getAccounts>>> = {};
async function getAccountsForTenant(tenantId: string) {
if (!accountsCache[tenantId]) {
accountsCache[tenantId] = await storage.getAccounts(tenantId);
}
return accountsCache[tenantId];
}

function resolveTenantFromAddress(address: string): string | null {
const lower = address.toLowerCase();
for (const [pattern, slug] of Object.entries(ADDRESS_TENANT_MAP)) {
if (lower.includes(pattern)) return slugToTenantId[slug] || null;
}
return null;
}

function resolveBankAccount(bankAcctStr: string): { tenantSlug: string; namePattern: string } | null {
const lower = bankAcctStr.toLowerCase();
// Try most specific match first (longest key)
const sorted = Object.entries(BANK_ACCOUNT_MAP).sort((a, b) => b[0].length - a[0].length);
for (const [pattern, mapping] of sorted) {
Comment on lines +1159 to +1163
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.

resolveBankAccount sorts BANK_ACCOUNT_MAP entries on every call, which makes per-row processing O(n log n) unnecessarily. Precompute the sorted entries once (outside the function) and iterate that array in resolveBankAccount.

Suggested change
function resolveBankAccount(bankAcctStr: string): { tenantSlug: string; namePattern: string } | null {
const lower = bankAcctStr.toLowerCase();
// Try most specific match first (longest key)
const sorted = Object.entries(BANK_ACCOUNT_MAP).sort((a, b) => b[0].length - a[0].length);
for (const [pattern, mapping] of sorted) {
// Try most specific match first (longest key)
const sortedBankAccountEntries = Object.entries(BANK_ACCOUNT_MAP).sort(
(a, b) => b[0].length - a[0].length,
);
function resolveBankAccount(bankAcctStr: string): { tenantSlug: string; namePattern: string } | null {
const lower = bankAcctStr.toLowerCase();
for (const [pattern, mapping] of sortedBankAccountEntries) {

Copilot uses AI. Check for mistakes.
if (lower.includes(pattern)) return mapping;
}
return null;
}

let imported = 0;
let skipped = 0;
let errors = 0;

for (let i = 1; i < lines.length; i++) {
const row = parseCSVLine(lines[i]);
if (row.length < 3) continue;

const paymentId = iPaymentId >= 0 ? row[iPaymentId]?.trim() : '';
const amountStr = iAmount >= 0 ? row[iAmount]?.trim() : '';
const dateDeposited = iDateDeposited >= 0 ? row[iDateDeposited]?.trim() : '';
const tenantName = iTenant >= 0 ? row[iTenant]?.trim() || '' : '';
const address = iAddress >= 0 ? row[iAddress]?.trim() || '' : '';
const note = iNote >= 0 ? row[iNote]?.trim() || '' : '';
const datePaid = iDatePaid >= 0 ? row[iDatePaid]?.trim() || '' : '';
const bankAccountStr = iBankAccount >= 0 ? row[iBankAccount]?.trim() || '' : '';
const leaseTitle = iLease >= 0 ? row[iLease]?.trim() || '' : '';
const paymentMethod = iPaymentMethod >= 0 ? row[iPaymentMethod]?.trim() || '' : '';

if (!paymentId || !amountStr || !dateDeposited) { skipped++; continue; }

const amount = parseFloat(amountStr.replace(/[$,]/g, ''));
if (isNaN(amount)) { skipped++; continue; }

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

const externalId = `tt-deposit:${paymentId}`;

try {
// Resolve tenant: prefer bank account mapping, fall back to address
let tenantId: string | null = null;
let accountId: string | null = null;
const bankMapping = resolveBankAccount(bankAccountStr);

if (bankMapping) {
tenantId = slugToTenantId[bankMapping.tenantSlug] || null;
}
if (!tenantId) {
tenantId = resolveTenantFromAddress(address);
}
if (!tenantId) {
// Default to ARIBIA MGMT for unresolved
tenantId = slugToTenantId['aribia-mgmt'] || Object.values(slugToTenantId)[0];
Comment on lines +1217 to +1219
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 Restrict deposit imports to the caller tenant

/api/import/turbotenant-deposits is mounted under the tenant-protected API, but this handler chooses tenantId from CSV-derived mappings (and defaults to aribia-mgmt) instead of the authenticated c.get('tenantId'). In the current app wiring, any user who can pass tenant middleware for one tenant can call this route and create transactions in other active tenants, which breaks tenant isolation and is a cross-tenant write vulnerability.

Useful? React with 👍 / 👎.

Comment on lines +1218 to +1219
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 unresolved-tenant fallback Object.values(slugToTenantId)[0] can route imports into an arbitrary tenant (and can be undefined if no tenants exist). That’s risky for data integrity. Prefer returning a 400 with a clear error (or skipping the row with an error counter) when neither bank account nor address can resolve a tenant.

Suggested change
// Default to ARIBIA MGMT for unresolved
tenantId = slugToTenantId['aribia-mgmt'] || Object.values(slugToTenantId)[0];
skipped++;
continue;

Copilot uses AI. Check for mistakes.
}

// Resolve account: find Mercury account matching the bank pattern
if (bankMapping && tenantId) {
const accounts = await getAccountsForTenant(tenantId);
const match = accounts.find((a) =>
a.name.toLowerCase().includes(bankMapping.namePattern.toLowerCase()),
);
if (match) accountId = match.id;
}
// If no account found in target tenant, check MGMT (many accounts live there)
if (!accountId) {
const mgmtId = slugToTenantId['aribia-mgmt'];
if (mgmtId) {
const mgmtAccounts = await getAccountsForTenant(mgmtId);
if (bankMapping) {
const match = mgmtAccounts.find((a) =>
a.name.toLowerCase().includes(bankMapping.namePattern.toLowerCase()),
);
if (match) {
accountId = match.id;
tenantId = mgmtId; // Account lives in MGMT tenant
}
}
if (!accountId) {
// Use first active MGMT account as fallback
const active = mgmtAccounts.find((a) => a.isActive);
if (active) { accountId = active.id; tenantId = mgmtId; }
}
}
}

if (!accountId || !tenantId) { skipped++; continue; }

// Dedup by external ID
const dup = await storage.getTransactionByExternalId(externalId, tenantId);
if (dup) { skipped++; continue; }

// Classify: fee income (8130 pattern) vs rental income (4010)
const isFee = bankAccountStr.toLowerCase().includes('fee') ||
bankAccountStr.toLowerCase().includes('8130');
const suggestedCoaCode = isFee ? '4020' : '4010';

const description = note || 'Rent Payment';

await storage.createTransaction({
tenantId,
accountId,
amount: String(amount),
type: 'income',
category: isFee ? 'fee_income' : 'rental_income',
description: `${description} — ${tenantName}`,
date,
payee: tenantName,
externalId,
suggestedCoaCode,
classificationConfidence: '0.900',
metadata: {
source: 'turbotenant_deposits',
paymentId,
paymentMethod: paymentMethod.replace(/\\_/g, '_'),
leaseAddress: address.replace(/\\/g, ''),
leaseTitle: leaseTitle.replace(/\\/g, ''),
datePaid: datePaid || undefined,
bankAccount: bankAccountStr.replace(/\\\*/g, '*'),
},
});
imported++;
} catch (e: any) {
console.warn('[import:tt-deposits] Row error:', e.message, { row: i, paymentId });
errors++;
}
}

ledgerLog(c, {
entityType: 'audit',
action: 'import.turbotenant_deposits',
metadata: { imported, skipped, errors, totalRows: lines.length - 1 },
}, c.env);

return c.json({ imported, skipped, errors, totalRows: lines.length - 1 });
});

// POST /api/import/turbotenant-rentroll — import TurboTenant rent roll snapshot
// Accepts raw CSV body with columns: Property, Unit, Tenants, Lease Start,
// Lease End, Security Deposit, Rent Amount, Total Unpaid, Total Past Due
// Upserts leases on matching properties. Does not create transactions.
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 comment says this route “Upserts leases on matching properties”, but the implementation only parses CSV and returns a read-only snapshot (no storage writes). Update the comment to match current behavior (or implement the upsert if that’s intended).

Suggested change
// Upserts leases on matching properties. Does not create transactions.
// Parses the CSV and returns a read-only snapshot. Does not upsert leases or create transactions.

Copilot uses AI. Check for mistakes.
importRoutes.post('/api/import/turbotenant-rentroll', async (c) => {
const storage = c.get('storage');

const body = await c.req.text();
const lines = body.split('\n').filter((l) => l.trim());

// Skip "Pulled on..." preamble line if present
let startLine = 0;
if (lines[0]?.toLowerCase().startsWith('pulled on') || lines[0]?.includes('\ufeff')) {
const cleaned = lines[0].replace('\ufeff', '').trim();
if (cleaned.toLowerCase().startsWith('pulled on')) startLine = 1;
}

if (lines.length - startLine < 2) {
return c.json({ error: 'CSV must have header + data rows' }, 400);
}

const cols = parseCSVLine(lines[startLine]);
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.

BOM handling here is incomplete: when the first line contains \uFEFF but is actually the header row (no “Pulled on…” preamble), cleaned is computed but never used, so parseCSVLine(lines[startLine]) may still see the BOM and colIdx(...) can fail. Strip a leading BOM from the actual header line unconditionally before parsing.

Suggested change
const cols = parseCSVLine(lines[startLine]);
const headerLine = (lines[startLine] ?? '').replace(/^\ufeff/, '');
const cols = parseCSVLine(headerLine);

Copilot uses AI. Check for mistakes.
const colIdx = (name: string) => cols.findIndex((col) => col.trim().toLowerCase() === name.toLowerCase());

const iProperty = colIdx('Property');
const iUnit = colIdx('Unit');
const iTenants = colIdx('Tenants');
const iLeaseStart = colIdx('Lease Start');
const iLeaseEnd = colIdx('Lease End');
const iSecDep = colIdx('Security Deposit');
const iRent = colIdx('Rent Amount');
const iUnpaid = colIdx('Total Unpaid');
const iPastDue = colIdx('Total Past Due');

if (iProperty < 0 || iTenants < 0 || iRent < 0) {
return c.json({ error: 'Missing required columns: Property, Tenants, Rent Amount' }, 400);
}

// Address → property matching
const ADDRESS_PROPERTY_MAP: Record<string, { tenantSlug: string; propertyName: string }> = {
'541 w addison': { tenantSlug: 'nicholas-bianchi', propertyName: 'Lakeside Loft' },
'550 w surf st c211': { tenantSlug: 'aribia-city-studio', propertyName: 'City Studio' },
'550 w surf st c504': { tenantSlug: 'nicholas-bianchi', propertyName: 'Cozy Castle' },
'carrera 76': { tenantSlug: 'aribia-llc', propertyName: 'Morada Mami' },
};

const allTenants = await storage.getTenants();
const slugToTenantId: Record<string, string> = {};
for (const t of allTenants) slugToTenantId[t.slug] = t.id;

Comment on lines +1349 to +1352
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.

allTenants/slugToTenantId are computed but never used in this route, which adds unnecessary DB work and makes the code harder to follow. Either remove this block or use it to return richer match info (e.g., tenantId/propertyId) as part of the response.

Suggested change
const allTenants = await storage.getTenants();
const slugToTenantId: Record<string, string> = {};
for (const t of allTenants) slugToTenantId[t.slug] = t.id;

Copilot uses AI. Check for mistakes.
const results: Array<{
property: string;
unit: string;
tenants: string;
leaseStart: string;
leaseEnd: string;
rentAmount: number;
totalUnpaid: number;
totalPastDue: number;
matched: boolean;
tenantSlug?: string;
}> = [];

for (let i = startLine + 1; i < lines.length; i++) {
const row = parseCSVLine(lines[i]);
if (row.length < 3) continue;

const property = iProperty >= 0 ? row[iProperty]?.trim().replace(/\\/g, '') || '' : '';
const unit = iUnit >= 0 ? row[iUnit]?.trim() || '' : '';
const tenants = iTenants >= 0 ? row[iTenants]?.trim() || '' : '';
const leaseStart = iLeaseStart >= 0 ? row[iLeaseStart]?.trim() || '' : '';
const leaseEnd = iLeaseEnd >= 0 ? row[iLeaseEnd]?.trim() || '' : '';
const rentStr = iRent >= 0 ? row[iRent]?.trim() || '0' : '0';
const unpaidStr = iUnpaid >= 0 ? row[iUnpaid]?.trim() || '0' : '0';
const pastDueStr = iPastDue >= 0 ? row[iPastDue]?.trim() || '0' : '0';

const rentAmount = parseFloat(rentStr.replace(/[$,]/g, ''));
const totalUnpaid = parseFloat(unpaidStr.replace(/[$,]/g, ''));
const totalPastDue = parseFloat(pastDueStr.replace(/[$,]/g, ''));

// Match to property
const lower = property.toLowerCase();
let matched = false;
let tenantSlug: string | undefined;
for (const [pattern, mapping] of Object.entries(ADDRESS_PROPERTY_MAP)) {
if (lower.includes(pattern)) {
matched = true;
tenantSlug = mapping.tenantSlug;
break;
}
}

results.push({
property, unit, tenants, leaseStart, leaseEnd,
rentAmount, totalUnpaid, totalPastDue,
matched, tenantSlug,
});
}

ledgerLog(c, {
entityType: 'audit',
action: 'import.turbotenant_rentroll',
metadata: { units: results.length, matched: results.filter((r) => r.matched).length },
}, c.env);

return c.json({
units: results.length,
matched: results.filter((r) => r.matched).length,
unmatched: results.filter((r) => !r.matched).length,
rentRoll: results,
});
});

// POST /api/import/mercury-csv — import Mercury bank CSV export
// Accepts raw CSV body. Parses Source Account to resolve tenant + account.
// Auto-creates accounts if needed. Deduplicates via external_id.
Expand Down
Loading