optimal-cli 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/agents/.gitkeep +0 -0
  2. package/agents/content-ops.md +227 -0
  3. package/agents/financial-ops.md +184 -0
  4. package/agents/infra-ops.md +206 -0
  5. package/agents/profiles.json +5 -0
  6. package/bin/optimal.ts +1731 -0
  7. package/docs/CLI-REFERENCE.md +361 -0
  8. package/lib/assets/index.ts +225 -0
  9. package/lib/assets.ts +124 -0
  10. package/lib/auth/index.ts +189 -0
  11. package/lib/board/index.ts +309 -0
  12. package/lib/board/types.ts +124 -0
  13. package/lib/bot/claim.ts +43 -0
  14. package/lib/bot/coordinator.ts +254 -0
  15. package/lib/bot/heartbeat.ts +37 -0
  16. package/lib/bot/index.ts +9 -0
  17. package/lib/bot/protocol.ts +99 -0
  18. package/lib/bot/reporter.ts +42 -0
  19. package/lib/bot/skills.ts +81 -0
  20. package/lib/budget/projections.ts +561 -0
  21. package/lib/budget/scenarios.ts +312 -0
  22. package/lib/cms/publish-blog.ts +129 -0
  23. package/lib/cms/strapi-client.ts +302 -0
  24. package/lib/config/registry.ts +228 -0
  25. package/lib/config/schema.ts +58 -0
  26. package/lib/config.ts +247 -0
  27. package/lib/errors.ts +129 -0
  28. package/lib/format.ts +120 -0
  29. package/lib/infra/.gitkeep +0 -0
  30. package/lib/infra/deploy.ts +70 -0
  31. package/lib/infra/migrate.ts +141 -0
  32. package/lib/newsletter/.gitkeep +0 -0
  33. package/lib/newsletter/distribute.ts +256 -0
  34. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  35. package/lib/newsletter/generate.ts +735 -0
  36. package/lib/returnpro/.gitkeep +0 -0
  37. package/lib/returnpro/anomalies.ts +258 -0
  38. package/lib/returnpro/audit.ts +194 -0
  39. package/lib/returnpro/diagnose.ts +400 -0
  40. package/lib/returnpro/kpis.ts +255 -0
  41. package/lib/returnpro/templates.ts +323 -0
  42. package/lib/returnpro/upload-income.ts +311 -0
  43. package/lib/returnpro/upload-netsuite.ts +696 -0
  44. package/lib/returnpro/upload-r1.ts +563 -0
  45. package/lib/returnpro/validate.ts +154 -0
  46. package/lib/social/meta.ts +228 -0
  47. package/lib/social/post-generator.ts +468 -0
  48. package/lib/social/publish.ts +301 -0
  49. package/lib/social/scraper.ts +503 -0
  50. package/lib/supabase.ts +25 -0
  51. package/lib/transactions/delete-batch.ts +258 -0
  52. package/lib/transactions/ingest.ts +659 -0
  53. package/lib/transactions/stamp.ts +654 -0
  54. package/package.json +15 -25
  55. package/dist/bin/optimal.d.ts +0 -2
  56. package/dist/bin/optimal.js +0 -995
  57. package/dist/lib/budget/projections.d.ts +0 -115
  58. package/dist/lib/budget/projections.js +0 -384
  59. package/dist/lib/budget/scenarios.d.ts +0 -93
  60. package/dist/lib/budget/scenarios.js +0 -214
  61. package/dist/lib/cms/publish-blog.d.ts +0 -62
  62. package/dist/lib/cms/publish-blog.js +0 -74
  63. package/dist/lib/cms/strapi-client.d.ts +0 -123
  64. package/dist/lib/cms/strapi-client.js +0 -213
  65. package/dist/lib/config.d.ts +0 -55
  66. package/dist/lib/config.js +0 -206
  67. package/dist/lib/infra/deploy.d.ts +0 -29
  68. package/dist/lib/infra/deploy.js +0 -58
  69. package/dist/lib/infra/migrate.d.ts +0 -34
  70. package/dist/lib/infra/migrate.js +0 -103
  71. package/dist/lib/kanban.d.ts +0 -46
  72. package/dist/lib/kanban.js +0 -118
  73. package/dist/lib/newsletter/distribute.d.ts +0 -52
  74. package/dist/lib/newsletter/distribute.js +0 -193
  75. package/dist/lib/newsletter/generate-insurance.js +0 -36
  76. package/dist/lib/newsletter/generate.d.ts +0 -104
  77. package/dist/lib/newsletter/generate.js +0 -571
  78. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  79. package/dist/lib/returnpro/anomalies.js +0 -166
  80. package/dist/lib/returnpro/audit.d.ts +0 -32
  81. package/dist/lib/returnpro/audit.js +0 -147
  82. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  83. package/dist/lib/returnpro/diagnose.js +0 -281
  84. package/dist/lib/returnpro/kpis.d.ts +0 -32
  85. package/dist/lib/returnpro/kpis.js +0 -192
  86. package/dist/lib/returnpro/templates.d.ts +0 -48
  87. package/dist/lib/returnpro/templates.js +0 -229
  88. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  89. package/dist/lib/returnpro/upload-income.js +0 -235
  90. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  91. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  92. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  93. package/dist/lib/returnpro/upload-r1.js +0 -398
  94. package/dist/lib/social/post-generator.d.ts +0 -83
  95. package/dist/lib/social/post-generator.js +0 -333
  96. package/dist/lib/social/publish.d.ts +0 -66
  97. package/dist/lib/social/publish.js +0 -226
  98. package/dist/lib/social/scraper.d.ts +0 -67
  99. package/dist/lib/social/scraper.js +0 -361
  100. package/dist/lib/supabase.d.ts +0 -4
  101. package/dist/lib/supabase.js +0 -20
  102. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  103. package/dist/lib/transactions/delete-batch.js +0 -203
  104. package/dist/lib/transactions/ingest.d.ts +0 -43
  105. package/dist/lib/transactions/ingest.js +0 -555
  106. package/dist/lib/transactions/stamp.d.ts +0 -51
  107. package/dist/lib/transactions/stamp.js +0 -524
@@ -0,0 +1,323 @@
1
+ import { writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import ExcelJS from 'exceljs'
4
+ import { getSupabase } from '../supabase.js'
5
+
6
+ // --- Types ---
7
+
8
+ export interface TemplateResult {
9
+ /** Absolute path where the XLSX was written */
10
+ outputPath: string
11
+ /** Number of account columns included */
12
+ accountCount: number
13
+ /** Number of program rows included */
14
+ programCount: number
15
+ /** Month string used (e.g. "Jan 2026") or undefined if no month was specified */
16
+ month: string | undefined
17
+ }
18
+
19
+ export interface GenerateNetSuiteTemplateOptions {
20
+ /**
21
+ * Fiscal year string, e.g. "2025" or "FY2026". Currently unused for filtering
22
+ * (all active programs/accounts are included regardless), but stored in the
23
+ * Instructions sheet for reference.
24
+ */
25
+ fiscalYear?: string
26
+ /**
27
+ * Optional month string in "MMM YYYY" format (e.g. "Jan 2026").
28
+ * When provided the template's Upload Date and Solution7 Date columns are
29
+ * pre-filled. When omitted those cells are left empty.
30
+ */
31
+ month?: string
32
+ }
33
+
34
+ // --- Internal types matching Supabase rows ---
35
+
36
+ interface ProgramIdRow {
37
+ program_code: string
38
+ master_program_name: string
39
+ is_primary: boolean
40
+ }
41
+
42
+ interface AccountRow {
43
+ account_code: string
44
+ account_id: number
45
+ netsuite_label: string | null
46
+ }
47
+
48
+ // --- Helpers ---
49
+
50
+ const MONTH_MAP: Record<string, string> = {
51
+ Jan: '01', Feb: '02', Mar: '03', Apr: '04',
52
+ May: '05', Jun: '06', Jul: '07', Aug: '08',
53
+ Sep: '09', Oct: '10', Nov: '11', Dec: '12',
54
+ }
55
+
56
+ /**
57
+ * Convert "Dec 2025" → "12/1/2025" (NetSuite upload date format).
58
+ * Returns null if the input is absent or malformed.
59
+ */
60
+ function monthToUploadDate(monthStr: string): string | null {
61
+ const parts = monthStr.trim().split(' ')
62
+ if (parts.length !== 2) return null
63
+ const [name, year] = parts
64
+ const num = MONTH_MAP[name]
65
+ if (!num) return null
66
+ return `${num}/1/${year}`
67
+ }
68
+
69
+ const PAGE_SIZE = 1000
70
+
71
+ /** Paginate all rows from a Supabase table, bypassing the 1000-row server cap. */
72
+ async function paginateAll<T>(
73
+ table: string,
74
+ select: string,
75
+ orderCol: string,
76
+ filters?: Record<string, string>,
77
+ ): Promise<T[]> {
78
+ const sb = getSupabase('returnpro')
79
+ const all: T[] = []
80
+ let from = 0
81
+
82
+ while (true) {
83
+ let q = sb.from(table).select(select).order(orderCol).range(from, from + PAGE_SIZE - 1)
84
+
85
+ if (filters) {
86
+ for (const [col, val] of Object.entries(filters)) {
87
+ q = q.eq(col, val)
88
+ }
89
+ }
90
+
91
+ const { data, error } = await q
92
+ if (error) throw new Error(`Fetch ${table} failed: ${error.message}`)
93
+ if (!data || data.length === 0) break
94
+
95
+ all.push(...(data as T[]))
96
+ if (data.length < PAGE_SIZE) break
97
+ from += PAGE_SIZE
98
+ }
99
+
100
+ return all
101
+ }
102
+
103
+ // --- Core ---
104
+
105
+ /**
106
+ * Generate a blank NetSuite upload template XLSX.
107
+ *
108
+ * The workbook mirrors the structure produced by dashboard-returnpro's
109
+ * `/api/admin/netsuite-template` route:
110
+ *
111
+ * Sheet 1 — Data Entry
112
+ * Row 1: headers — Master Program | Program ID | Date (Upload) | Date (Solution7) | <account_code>…
113
+ * Row 2+: one row per active program, upload/solution7 date cells pre-filled when `month` is given
114
+ *
115
+ * Sheet 2 — Account Reference
116
+ * Account Code | Account ID | NetSuite Label
117
+ *
118
+ * Sheet 3 — Instructions
119
+ * Key/value metadata + usage instructions
120
+ *
121
+ * Accounts are ordered by account_id; programs are ordered by
122
+ * master_program_name then program_code (matching the route).
123
+ *
124
+ * @param outputPath Destination file path (will be created or overwritten).
125
+ * @param options Optional month / fiscalYear metadata.
126
+ * @returns TemplateResult with final path and counts.
127
+ */
128
+ export async function generateNetSuiteTemplate(
129
+ outputPath: string,
130
+ options?: GenerateNetSuiteTemplateOptions,
131
+ ): Promise<TemplateResult> {
132
+ const resolvedPath = path.resolve(outputPath)
133
+ const month = options?.month
134
+ const fiscalYear = options?.fiscalYear
135
+
136
+ // Compute date strings if a month was provided
137
+ const uploadDate = month ? monthToUploadDate(month) : null
138
+ if (month && !uploadDate) {
139
+ throw new Error(`Invalid month format: "${month}". Expected "MMM YYYY" (e.g. "Jan 2026").`)
140
+ }
141
+ // Solution7 date prefixes the month with a leading apostrophe so Excel treats it as text
142
+ const solution7Date = month ? `'${month}` : null
143
+
144
+ // --- Fetch dimension data ---
145
+ const [programs, accounts] = await Promise.all([
146
+ paginateAll<ProgramIdRow>(
147
+ 'dim_program_id',
148
+ 'program_code,master_program_name,is_primary',
149
+ 'master_program_name',
150
+ { is_active: 'true' },
151
+ ),
152
+ paginateAll<AccountRow>(
153
+ 'dim_account',
154
+ 'account_code,account_id,netsuite_label',
155
+ 'account_id',
156
+ ),
157
+ ])
158
+
159
+ // Secondary sort: within same master_program_name, order by program_code
160
+ programs.sort((a, b) =>
161
+ a.master_program_name.localeCompare(b.master_program_name)
162
+ || a.program_code.localeCompare(b.program_code),
163
+ )
164
+
165
+ // --- Build workbook ---
166
+ const workbook = new ExcelJS.Workbook()
167
+ workbook.creator = 'optimal-cli'
168
+ workbook.created = new Date()
169
+
170
+ // ------------------------------------------------------------------ //
171
+ // Sheet 1: Data Entry
172
+ // ------------------------------------------------------------------ //
173
+ const dataSheet = workbook.addWorksheet('Data Entry')
174
+
175
+ // Build header row
176
+ const fixedHeaders = ['Master Program', 'Program ID', 'Date (Upload)', 'Date (Solution7)']
177
+ const accountHeaders = accounts.map(a => a.account_code)
178
+ const allHeaders = [...fixedHeaders, ...accountHeaders]
179
+
180
+ // Style the header row
181
+ const headerRow = dataSheet.addRow(allHeaders)
182
+ headerRow.font = { bold: true }
183
+ headerRow.fill = {
184
+ type: 'pattern',
185
+ pattern: 'solid',
186
+ fgColor: { argb: 'FFD9E1F2' }, // light blue
187
+ }
188
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' }
189
+
190
+ // Add one data row per program
191
+ for (const program of programs) {
192
+ const rowValues: (string | null)[] = [
193
+ program.master_program_name,
194
+ program.program_code,
195
+ uploadDate ?? null,
196
+ solution7Date ?? null,
197
+ // Account value cells start empty
198
+ ...accounts.map(() => null),
199
+ ]
200
+ dataSheet.addRow(rowValues)
201
+ }
202
+
203
+ // Freeze the header row and first two columns
204
+ dataSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1 }]
205
+
206
+ // Set column widths
207
+ const colWidths = [
208
+ 45, // Master Program
209
+ 35, // Program ID
210
+ 15, // Date (Upload)
211
+ 15, // Date (Solution7)
212
+ ...accounts.map(() => 12),
213
+ ]
214
+ dataSheet.columns = allHeaders.map((header, i) => ({
215
+ header, // overwritten below via addRow — just sets .key
216
+ key: header,
217
+ width: colWidths[i],
218
+ }))
219
+
220
+ // Re-apply the styled header row (addRow above is already row 1; columns
221
+ // setter re-generates a header via key — replace it with our styled one)
222
+ // The columns setter inserts an extra header row if we set `header:`.
223
+ // Avoid that by only setting width/key after rows are added.
224
+ // Reset: clear columns, re-add rows fresh.
225
+ // ----- Rebuild cleanly to avoid double-header -----
226
+ dataSheet.spliceRows(1, dataSheet.rowCount) // clear all rows
227
+
228
+ // Re-add styled header
229
+ const hdr = dataSheet.addRow(allHeaders)
230
+ hdr.font = { bold: true }
231
+ hdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E1F2' } }
232
+ hdr.alignment = { vertical: 'middle', horizontal: 'center' }
233
+ hdr.height = 18
234
+
235
+ // Re-add data rows
236
+ for (const program of programs) {
237
+ const vals: (string | null)[] = [
238
+ program.master_program_name,
239
+ program.program_code,
240
+ uploadDate ?? null,
241
+ solution7Date ?? null,
242
+ ...accounts.map(() => null),
243
+ ]
244
+ dataSheet.addRow(vals)
245
+ }
246
+
247
+ // Column widths (set by index, 1-based)
248
+ const widths = [45, 35, 15, 15, ...accounts.map(() => 12)]
249
+ widths.forEach((w, i) => {
250
+ const col = dataSheet.getColumn(i + 1)
251
+ col.width = w
252
+ })
253
+
254
+ dataSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1 }]
255
+
256
+ // ------------------------------------------------------------------ //
257
+ // Sheet 2: Account Reference
258
+ // ------------------------------------------------------------------ //
259
+ const accountSheet = workbook.addWorksheet('Account Reference')
260
+
261
+ const acctHdr = accountSheet.addRow(['Account Code', 'Account ID', 'NetSuite Label'])
262
+ acctHdr.font = { bold: true }
263
+ acctHdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E1F2' } }
264
+
265
+ for (const a of accounts) {
266
+ accountSheet.addRow([a.account_code, a.account_id, a.netsuite_label ?? ''])
267
+ }
268
+
269
+ accountSheet.getColumn(1).width = 20
270
+ accountSheet.getColumn(2).width = 12
271
+ accountSheet.getColumn(3).width = 60
272
+
273
+ // ------------------------------------------------------------------ //
274
+ // Sheet 3: Instructions
275
+ // ------------------------------------------------------------------ //
276
+ const instrSheet = workbook.addWorksheet('Instructions')
277
+
278
+ const instrHdr = instrSheet.addRow(['Field', 'Value'])
279
+ instrHdr.font = { bold: true }
280
+ instrHdr.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9E1F2' } }
281
+
282
+ const instrRows: [string, string][] = [
283
+ ['Template Generated', new Date().toISOString()],
284
+ ['Month', month ?? '(not specified)'],
285
+ ['Upload Date Format', uploadDate ?? '(fill manually — format: MM/1/YYYY)'],
286
+ ['Fiscal Year', fiscalYear ?? '(not specified)'],
287
+ ['Total Programs', String(programs.length)],
288
+ ['Total Account Columns', String(accounts.length)],
289
+ ['Note', 'Excludes deprecated programs (is_active=false)'],
290
+ ['', ''],
291
+ ['Instructions', '1. Fill in account values using Solution7 formulas'],
292
+ ['', '2. Use the "Date (Solution7)" column value in your formulas'],
293
+ ['', '3. The "Date (Upload)" column has the format needed for stg_financials_raw'],
294
+ ['', '4. Save and upload to the dashboard via Browse > stg_financials_raw'],
295
+ ['', ''],
296
+ ['Upload Format', 'When uploading, the system expects these columns:'],
297
+ ['', ' - program_code (from "Program ID" column)'],
298
+ ['', ' - master_program (from "Master Program" column)'],
299
+ ['', ' - date (from "Date (Upload)" column)'],
300
+ ['', ' - account_code (header row value)'],
301
+ ['', ' - amount (cell value)'],
302
+ ]
303
+
304
+ for (const [field, value] of instrRows) {
305
+ instrSheet.addRow([field, value])
306
+ }
307
+
308
+ instrSheet.getColumn(1).width = 25
309
+ instrSheet.getColumn(2).width = 70
310
+
311
+ // ------------------------------------------------------------------ //
312
+ // Write file
313
+ // ------------------------------------------------------------------ //
314
+ const buffer = await workbook.xlsx.writeBuffer()
315
+ await writeFile(resolvedPath, Buffer.from(buffer))
316
+
317
+ return {
318
+ outputPath: resolvedPath,
319
+ accountCount: accounts.length,
320
+ programCount: programs.length,
321
+ month,
322
+ }
323
+ }
@@ -0,0 +1,311 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { getSupabase } from '../supabase.js'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface IncomeStatementResult {
9
+ /** Period loaded, e.g. "2025-04" */
10
+ period: string
11
+ /** Human-readable month label parsed from CSV, e.g. "Apr 2025" */
12
+ monthLabel: string
13
+ /** Rows successfully upserted (inserted or updated) */
14
+ upserted: number
15
+ /** Rows skipped due to parse errors */
16
+ skipped: number
17
+ /** Non-fatal warnings / parse error messages */
18
+ warnings: string[]
19
+ }
20
+
21
+ // Internal row shape before DB upsert
22
+ interface ParsedRow {
23
+ account_code: string
24
+ netsuite_label: string
25
+ total_amount: number
26
+ }
27
+
28
+ // Shape returned from Supabase upsert with return=representation
29
+ interface UpsertedRow {
30
+ id: number
31
+ account_code: string
32
+ total_amount: number
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // CSV parsing helpers (mirrors dashboard-returnpro/lib/income-statement-parser.ts)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Parse a single CSV line, handling quoted fields and escaped double-quotes.
41
+ */
42
+ function parseCsvLine(line: string): string[] {
43
+ const result: string[] = []
44
+ let current = ''
45
+ let inQuotes = false
46
+ let i = 0
47
+
48
+ while (i < line.length) {
49
+ const char = line[i]
50
+
51
+ if (char === '"') {
52
+ if (!inQuotes) {
53
+ inQuotes = true
54
+ i++
55
+ continue
56
+ }
57
+ // Inside quotes — check for escaped quote
58
+ if (line[i + 1] === '"') {
59
+ current += '"'
60
+ i += 2
61
+ continue
62
+ }
63
+ inQuotes = false
64
+ i++
65
+ continue
66
+ }
67
+
68
+ if (char === ',' && !inQuotes) {
69
+ result.push(current.trim())
70
+ current = ''
71
+ i++
72
+ continue
73
+ }
74
+
75
+ current += char
76
+ i++
77
+ }
78
+
79
+ result.push(current.trim())
80
+ return result
81
+ }
82
+
83
+ /**
84
+ * Parse a currency string like "$1,234.56" or "($1,234.56)" to a number.
85
+ */
86
+ function parseCurrency(value: string): number {
87
+ if (!value || value.trim() === '') return 0
88
+ const cleaned = value.trim()
89
+ const isNegative = cleaned.startsWith('(') && cleaned.endsWith(')')
90
+ const numericStr = cleaned.replace(/[$,()]/g, '').trim()
91
+ if (numericStr === '' || numericStr === '-') return 0
92
+ const parsed = parseFloat(numericStr)
93
+ if (isNaN(parsed)) return 0
94
+ return isNegative ? -parsed : parsed
95
+ }
96
+
97
+ /**
98
+ * Extract account_code and netsuite_label from a row's first column.
99
+ * Expects format "30010 - B2B Owned Sales".
100
+ * Returns null for header/summary rows that should be skipped.
101
+ */
102
+ function extractAccountCode(
103
+ label: string,
104
+ ): { account_code: string; netsuite_label: string } | null {
105
+ if (!label) return null
106
+ const trimmed = label.trim()
107
+
108
+ const SKIP = [
109
+ '',
110
+ 'Ordinary Income/Expense',
111
+ 'Income',
112
+ 'Cost Of Sales',
113
+ 'Expense',
114
+ 'Other Income and Expenses',
115
+ 'Other Income',
116
+ 'Other Expense',
117
+ ]
118
+ if (SKIP.includes(trimmed)) return null
119
+ if (
120
+ trimmed.startsWith('Total -') ||
121
+ trimmed.startsWith('Gross Profit') ||
122
+ trimmed.startsWith('Net Ordinary Income') ||
123
+ trimmed.startsWith('Net Other Income') ||
124
+ trimmed.startsWith('Net Income')
125
+ ) {
126
+ return null
127
+ }
128
+
129
+ // Pattern: "XXXXX - Label"
130
+ const match = trimmed.match(/^(\d{5})\s*-\s*(.+)$/)
131
+ if (!match) return null
132
+
133
+ return {
134
+ account_code: match[1],
135
+ netsuite_label: match[2].trim(),
136
+ }
137
+ }
138
+
139
+ const MONTH_MAP: Record<string, string> = {
140
+ jan: '01', january: '01',
141
+ feb: '02', february: '02',
142
+ mar: '03', march: '03',
143
+ apr: '04', april: '04',
144
+ may: '05',
145
+ jun: '06', june: '06',
146
+ jul: '07', july: '07',
147
+ aug: '08', august: '08',
148
+ sep: '09', september: '09',
149
+ oct: '10', october: '10',
150
+ nov: '11', november: '11',
151
+ dec: '12', december: '12',
152
+ }
153
+
154
+ /**
155
+ * Convert "Apr 2025" -> "2025-04". Returns "" on failure.
156
+ */
157
+ function monthLabelToPeriod(label: string): string {
158
+ const match = label.trim().match(/^([a-zA-Z]+)\s+(\d{4})$/i)
159
+ if (!match) return ''
160
+ const month = MONTH_MAP[match[1].toLowerCase()]
161
+ if (!month) return ''
162
+ return `${match[2]}-${month}`
163
+ }
164
+
165
+ interface ParseResult {
166
+ period: string
167
+ monthLabel: string
168
+ rows: ParsedRow[]
169
+ errors: string[]
170
+ }
171
+
172
+ /**
173
+ * Parse a NetSuite income statement CSV text into rows.
174
+ *
175
+ * NetSuite export format:
176
+ * Row 4 (index 3): Month label — "Apr 2025"
177
+ * Row 7 (index 6): Column headers (last "Total" col is the consolidated amount)
178
+ * Row 9+ (index 8+): Data rows
179
+ */
180
+ function parseIncomeStatementCSV(csvText: string): ParseResult {
181
+ const lines = csvText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
182
+ const errors: string[] = []
183
+ const rows: ParsedRow[] = []
184
+
185
+ // Month label is on row 4 (0-indexed: 3)
186
+ const monthLabel = lines[3]?.trim() ?? ''
187
+ const period = monthLabelToPeriod(monthLabel)
188
+
189
+ if (!period) {
190
+ errors.push(`Could not parse period from row 4: "${monthLabel}"`)
191
+ }
192
+
193
+ // Find "Total" column index from header row (row 7, index 6)
194
+ const headerLine = lines[6] ?? ''
195
+ const headers = parseCsvLine(headerLine)
196
+ let totalColIdx = headers.length - 1
197
+ for (let i = headers.length - 1; i >= 0; i--) {
198
+ if (headers[i].toLowerCase().includes('total')) {
199
+ totalColIdx = i
200
+ break
201
+ }
202
+ }
203
+
204
+ // Data rows start at index 8 (row 9)
205
+ for (let i = 8; i < lines.length; i++) {
206
+ const line = lines[i]
207
+ if (!line || line.trim() === '') continue
208
+
209
+ const cols = parseCsvLine(line)
210
+ if (cols.length === 0) continue
211
+
212
+ const acctInfo = extractAccountCode(cols[0])
213
+ if (!acctInfo) continue
214
+
215
+ const totalStr = cols[totalColIdx] ?? ''
216
+ const amount = parseCurrency(totalStr)
217
+
218
+ rows.push({
219
+ account_code: acctInfo.account_code,
220
+ netsuite_label: acctInfo.netsuite_label,
221
+ total_amount: amount,
222
+ })
223
+ }
224
+
225
+ if (rows.length === 0) {
226
+ errors.push('No valid account rows found in CSV')
227
+ }
228
+
229
+ return { period, monthLabel, rows, errors }
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Core export
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Upload a confirmed income statement CSV into `confirmed_income_statements`.
238
+ *
239
+ * Reads the CSV at `filePath`, parses it using the same logic as the
240
+ * dashboard's income-statement-parser, then upserts all account rows into
241
+ * ReturnPro Supabase (conflict resolution: account_code + period).
242
+ *
243
+ * @param filePath Absolute path to the NetSuite income statement CSV.
244
+ * @param userId User ID to associate with the upload (stored in `uploaded_by` if column exists; otherwise ignored).
245
+ * @param periodOverride Optional period override in "YYYY-MM" format.
246
+ * @returns IncomeStatementResult summary.
247
+ */
248
+ export async function uploadIncomeStatements(
249
+ filePath: string,
250
+ userId: string,
251
+ periodOverride?: string,
252
+ ): Promise<IncomeStatementResult> {
253
+ // 1. Read file
254
+ const csvText = readFileSync(filePath, 'utf-8')
255
+
256
+ // 2. Parse CSV
257
+ const parsed = parseIncomeStatementCSV(csvText)
258
+
259
+ // 3. Resolve period
260
+ const period = periodOverride ?? parsed.period
261
+
262
+ if (!period) {
263
+ throw new Error(
264
+ `Could not detect period from CSV. Provide a periodOverride (e.g. "2025-04"). Parse errors: ${parsed.errors.join('; ')}`,
265
+ )
266
+ }
267
+
268
+ if (parsed.rows.length === 0) {
269
+ throw new Error(
270
+ `No valid account rows found in ${filePath}. Parse errors: ${parsed.errors.join('; ')}`,
271
+ )
272
+ }
273
+
274
+ // 4. Build insert rows
275
+ const now = new Date().toISOString()
276
+ const insertRows = parsed.rows.map((row) => ({
277
+ account_code: row.account_code,
278
+ netsuite_label: row.netsuite_label,
279
+ period,
280
+ total_amount: row.total_amount,
281
+ source: 'netsuite',
282
+ updated_at: now,
283
+ }))
284
+
285
+ // 5. Upsert into confirmed_income_statements
286
+ // Conflict target: (account_code, period) — merge-duplicates via onConflict
287
+ const sb = getSupabase('returnpro')
288
+
289
+ const { data, error } = await sb
290
+ .from('confirmed_income_statements')
291
+ .upsert(insertRows, {
292
+ onConflict: 'account_code,period',
293
+ ignoreDuplicates: false,
294
+ })
295
+ .select('id,account_code,total_amount')
296
+
297
+ if (error) {
298
+ throw new Error(`Supabase upsert failed: ${error.message}${error.hint ? ` (Hint: ${error.hint})` : ''}`)
299
+ }
300
+
301
+ const upserted = (data as UpsertedRow[] | null)?.length ?? 0
302
+ const skipped = parsed.rows.length - upserted
303
+
304
+ return {
305
+ period,
306
+ monthLabel: parsed.monthLabel,
307
+ upserted,
308
+ skipped,
309
+ warnings: parsed.errors,
310
+ }
311
+ }