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.
- package/agents/.gitkeep +0 -0
- package/agents/content-ops.md +227 -0
- package/agents/financial-ops.md +184 -0
- package/agents/infra-ops.md +206 -0
- package/agents/profiles.json +5 -0
- package/bin/optimal.ts +1731 -0
- package/docs/CLI-REFERENCE.md +361 -0
- package/lib/assets/index.ts +225 -0
- package/lib/assets.ts +124 -0
- package/lib/auth/index.ts +189 -0
- package/lib/board/index.ts +309 -0
- package/lib/board/types.ts +124 -0
- package/lib/bot/claim.ts +43 -0
- package/lib/bot/coordinator.ts +254 -0
- package/lib/bot/heartbeat.ts +37 -0
- package/lib/bot/index.ts +9 -0
- package/lib/bot/protocol.ts +99 -0
- package/lib/bot/reporter.ts +42 -0
- package/lib/bot/skills.ts +81 -0
- package/lib/budget/projections.ts +561 -0
- package/lib/budget/scenarios.ts +312 -0
- package/lib/cms/publish-blog.ts +129 -0
- package/lib/cms/strapi-client.ts +302 -0
- package/lib/config/registry.ts +228 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/errors.ts +129 -0
- package/lib/format.ts +120 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -0
- package/lib/newsletter/.gitkeep +0 -0
- package/lib/newsletter/distribute.ts +256 -0
- package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
- package/lib/newsletter/generate.ts +735 -0
- package/lib/returnpro/.gitkeep +0 -0
- package/lib/returnpro/anomalies.ts +258 -0
- package/lib/returnpro/audit.ts +194 -0
- package/lib/returnpro/diagnose.ts +400 -0
- package/lib/returnpro/kpis.ts +255 -0
- package/lib/returnpro/templates.ts +323 -0
- package/lib/returnpro/upload-income.ts +311 -0
- package/lib/returnpro/upload-netsuite.ts +696 -0
- package/lib/returnpro/upload-r1.ts +563 -0
- package/lib/returnpro/validate.ts +154 -0
- package/lib/social/meta.ts +228 -0
- package/lib/social/post-generator.ts +468 -0
- package/lib/social/publish.ts +301 -0
- package/lib/social/scraper.ts +503 -0
- package/lib/supabase.ts +25 -0
- package/lib/transactions/delete-batch.ts +258 -0
- package/lib/transactions/ingest.ts +659 -0
- package/lib/transactions/stamp.ts +654 -0
- package/package.json +15 -25
- package/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -995
- package/dist/lib/budget/projections.d.ts +0 -115
- package/dist/lib/budget/projections.js +0 -384
- package/dist/lib/budget/scenarios.d.ts +0 -93
- package/dist/lib/budget/scenarios.js +0 -214
- package/dist/lib/cms/publish-blog.d.ts +0 -62
- package/dist/lib/cms/publish-blog.js +0 -74
- package/dist/lib/cms/strapi-client.d.ts +0 -123
- package/dist/lib/cms/strapi-client.js +0 -213
- package/dist/lib/config.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- package/dist/lib/infra/deploy.d.ts +0 -29
- package/dist/lib/infra/deploy.js +0 -58
- package/dist/lib/infra/migrate.d.ts +0 -34
- package/dist/lib/infra/migrate.js +0 -103
- package/dist/lib/kanban.d.ts +0 -46
- package/dist/lib/kanban.js +0 -118
- package/dist/lib/newsletter/distribute.d.ts +0 -52
- package/dist/lib/newsletter/distribute.js +0 -193
- package/dist/lib/newsletter/generate-insurance.js +0 -36
- package/dist/lib/newsletter/generate.d.ts +0 -104
- package/dist/lib/newsletter/generate.js +0 -571
- package/dist/lib/returnpro/anomalies.d.ts +0 -64
- package/dist/lib/returnpro/anomalies.js +0 -166
- package/dist/lib/returnpro/audit.d.ts +0 -32
- package/dist/lib/returnpro/audit.js +0 -147
- package/dist/lib/returnpro/diagnose.d.ts +0 -52
- package/dist/lib/returnpro/diagnose.js +0 -281
- package/dist/lib/returnpro/kpis.d.ts +0 -32
- package/dist/lib/returnpro/kpis.js +0 -192
- package/dist/lib/returnpro/templates.d.ts +0 -48
- package/dist/lib/returnpro/templates.js +0 -229
- package/dist/lib/returnpro/upload-income.d.ts +0 -25
- package/dist/lib/returnpro/upload-income.js +0 -235
- package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
- package/dist/lib/returnpro/upload-netsuite.js +0 -566
- package/dist/lib/returnpro/upload-r1.d.ts +0 -48
- package/dist/lib/returnpro/upload-r1.js +0 -398
- package/dist/lib/social/post-generator.d.ts +0 -83
- package/dist/lib/social/post-generator.js +0 -333
- package/dist/lib/social/publish.d.ts +0 -66
- package/dist/lib/social/publish.js +0 -226
- package/dist/lib/social/scraper.d.ts +0 -67
- package/dist/lib/social/scraper.js +0 -361
- package/dist/lib/supabase.d.ts +0 -4
- package/dist/lib/supabase.js +0 -20
- package/dist/lib/transactions/delete-batch.d.ts +0 -60
- package/dist/lib/transactions/delete-batch.js +0 -203
- package/dist/lib/transactions/ingest.d.ts +0 -43
- package/dist/lib/transactions/ingest.js +0 -555
- package/dist/lib/transactions/stamp.d.ts +0 -51
- 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
|
+
}
|