-
Notifications
You must be signed in to change notification settings - Fork 0
feat: TurboTenant deposits + rent roll import #106
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,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]); | ||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||
| // 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
|
||||||||||||||||||||||||||||
| 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) { |
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.
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 👍 / 👎.
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.
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.
| // Default to ARIBIA MGMT for unresolved | |
| tenantId = slugToTenantId['aribia-mgmt'] || Object.values(slugToTenantId)[0]; | |
| skipped++; | |
| continue; |
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.
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).
| // 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
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.
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.
| const cols = parseCSVLine(lines[startLine]); | |
| const headerLine = (lines[startLine] ?? '').replace(/^\ufeff/, ''); | |
| const cols = parseCSVLine(headerLine); |
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.
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.
| const allTenants = await storage.getTenants(); | |
| const slugToTenantId: Record<string, string> = {}; | |
| for (const t of allTenants) slugToTenantId[t.slug] = t.id; |
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.
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\uFEFFfrom the header line before callingparseCSVLine.