optimal-cli 0.1.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/README.md +175 -0
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +995 -0
- package/dist/lib/budget/projections.d.ts +115 -0
- package/dist/lib/budget/projections.js +384 -0
- package/dist/lib/budget/scenarios.d.ts +93 -0
- package/dist/lib/budget/scenarios.js +214 -0
- package/dist/lib/cms/publish-blog.d.ts +62 -0
- package/dist/lib/cms/publish-blog.js +74 -0
- package/dist/lib/cms/strapi-client.d.ts +123 -0
- package/dist/lib/cms/strapi-client.js +213 -0
- package/dist/lib/config.d.ts +55 -0
- package/dist/lib/config.js +206 -0
- package/dist/lib/infra/deploy.d.ts +29 -0
- package/dist/lib/infra/deploy.js +58 -0
- package/dist/lib/infra/migrate.d.ts +34 -0
- package/dist/lib/infra/migrate.js +103 -0
- package/dist/lib/kanban.d.ts +46 -0
- package/dist/lib/kanban.js +118 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
- package/dist/lib/newsletter/generate-insurance.js +36 -0
- package/dist/lib/newsletter/generate.d.ts +104 -0
- package/dist/lib/newsletter/generate.js +571 -0
- package/dist/lib/returnpro/anomalies.d.ts +64 -0
- package/dist/lib/returnpro/anomalies.js +166 -0
- package/dist/lib/returnpro/audit.d.ts +32 -0
- package/dist/lib/returnpro/audit.js +147 -0
- package/dist/lib/returnpro/diagnose.d.ts +52 -0
- package/dist/lib/returnpro/diagnose.js +281 -0
- package/dist/lib/returnpro/kpis.d.ts +32 -0
- package/dist/lib/returnpro/kpis.js +192 -0
- package/dist/lib/returnpro/templates.d.ts +48 -0
- package/dist/lib/returnpro/templates.js +229 -0
- package/dist/lib/returnpro/upload-income.d.ts +25 -0
- package/dist/lib/returnpro/upload-income.js +235 -0
- package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
- package/dist/lib/returnpro/upload-netsuite.js +566 -0
- package/dist/lib/returnpro/upload-r1.d.ts +48 -0
- package/dist/lib/returnpro/upload-r1.js +398 -0
- package/dist/lib/social/post-generator.d.ts +83 -0
- package/dist/lib/social/post-generator.js +333 -0
- package/dist/lib/social/publish.d.ts +66 -0
- package/dist/lib/social/publish.js +226 -0
- package/dist/lib/social/scraper.d.ts +67 -0
- package/dist/lib/social/scraper.js +361 -0
- package/dist/lib/supabase.d.ts +4 -0
- package/dist/lib/supabase.js +20 -0
- package/dist/lib/transactions/delete-batch.d.ts +60 -0
- package/dist/lib/transactions/delete-batch.js +203 -0
- package/dist/lib/transactions/ingest.d.ts +43 -0
- package/dist/lib/transactions/ingest.js +555 -0
- package/dist/lib/transactions/stamp.d.ts +51 -0
- package/dist/lib/transactions/stamp.js +524 -0
- package/package.json +50 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { getSupabase } from '../supabase.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// CSV parsing helpers (mirrors dashboard-returnpro/lib/income-statement-parser.ts)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
/**
|
|
7
|
+
* Parse a single CSV line, handling quoted fields and escaped double-quotes.
|
|
8
|
+
*/
|
|
9
|
+
function parseCsvLine(line) {
|
|
10
|
+
const result = [];
|
|
11
|
+
let current = '';
|
|
12
|
+
let inQuotes = false;
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < line.length) {
|
|
15
|
+
const char = line[i];
|
|
16
|
+
if (char === '"') {
|
|
17
|
+
if (!inQuotes) {
|
|
18
|
+
inQuotes = true;
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
// Inside quotes — check for escaped quote
|
|
23
|
+
if (line[i + 1] === '"') {
|
|
24
|
+
current += '"';
|
|
25
|
+
i += 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
inQuotes = false;
|
|
29
|
+
i++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (char === ',' && !inQuotes) {
|
|
33
|
+
result.push(current.trim());
|
|
34
|
+
current = '';
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
current += char;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
result.push(current.trim());
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse a currency string like "$1,234.56" or "($1,234.56)" to a number.
|
|
46
|
+
*/
|
|
47
|
+
function parseCurrency(value) {
|
|
48
|
+
if (!value || value.trim() === '')
|
|
49
|
+
return 0;
|
|
50
|
+
const cleaned = value.trim();
|
|
51
|
+
const isNegative = cleaned.startsWith('(') && cleaned.endsWith(')');
|
|
52
|
+
const numericStr = cleaned.replace(/[$,()]/g, '').trim();
|
|
53
|
+
if (numericStr === '' || numericStr === '-')
|
|
54
|
+
return 0;
|
|
55
|
+
const parsed = parseFloat(numericStr);
|
|
56
|
+
if (isNaN(parsed))
|
|
57
|
+
return 0;
|
|
58
|
+
return isNegative ? -parsed : parsed;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Extract account_code and netsuite_label from a row's first column.
|
|
62
|
+
* Expects format "30010 - B2B Owned Sales".
|
|
63
|
+
* Returns null for header/summary rows that should be skipped.
|
|
64
|
+
*/
|
|
65
|
+
function extractAccountCode(label) {
|
|
66
|
+
if (!label)
|
|
67
|
+
return null;
|
|
68
|
+
const trimmed = label.trim();
|
|
69
|
+
const SKIP = [
|
|
70
|
+
'',
|
|
71
|
+
'Ordinary Income/Expense',
|
|
72
|
+
'Income',
|
|
73
|
+
'Cost Of Sales',
|
|
74
|
+
'Expense',
|
|
75
|
+
'Other Income and Expenses',
|
|
76
|
+
'Other Income',
|
|
77
|
+
'Other Expense',
|
|
78
|
+
];
|
|
79
|
+
if (SKIP.includes(trimmed))
|
|
80
|
+
return null;
|
|
81
|
+
if (trimmed.startsWith('Total -') ||
|
|
82
|
+
trimmed.startsWith('Gross Profit') ||
|
|
83
|
+
trimmed.startsWith('Net Ordinary Income') ||
|
|
84
|
+
trimmed.startsWith('Net Other Income') ||
|
|
85
|
+
trimmed.startsWith('Net Income')) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// Pattern: "XXXXX - Label"
|
|
89
|
+
const match = trimmed.match(/^(\d{5})\s*-\s*(.+)$/);
|
|
90
|
+
if (!match)
|
|
91
|
+
return null;
|
|
92
|
+
return {
|
|
93
|
+
account_code: match[1],
|
|
94
|
+
netsuite_label: match[2].trim(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const MONTH_MAP = {
|
|
98
|
+
jan: '01', january: '01',
|
|
99
|
+
feb: '02', february: '02',
|
|
100
|
+
mar: '03', march: '03',
|
|
101
|
+
apr: '04', april: '04',
|
|
102
|
+
may: '05',
|
|
103
|
+
jun: '06', june: '06',
|
|
104
|
+
jul: '07', july: '07',
|
|
105
|
+
aug: '08', august: '08',
|
|
106
|
+
sep: '09', september: '09',
|
|
107
|
+
oct: '10', october: '10',
|
|
108
|
+
nov: '11', november: '11',
|
|
109
|
+
dec: '12', december: '12',
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Convert "Apr 2025" -> "2025-04". Returns "" on failure.
|
|
113
|
+
*/
|
|
114
|
+
function monthLabelToPeriod(label) {
|
|
115
|
+
const match = label.trim().match(/^([a-zA-Z]+)\s+(\d{4})$/i);
|
|
116
|
+
if (!match)
|
|
117
|
+
return '';
|
|
118
|
+
const month = MONTH_MAP[match[1].toLowerCase()];
|
|
119
|
+
if (!month)
|
|
120
|
+
return '';
|
|
121
|
+
return `${match[2]}-${month}`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Parse a NetSuite income statement CSV text into rows.
|
|
125
|
+
*
|
|
126
|
+
* NetSuite export format:
|
|
127
|
+
* Row 4 (index 3): Month label — "Apr 2025"
|
|
128
|
+
* Row 7 (index 6): Column headers (last "Total" col is the consolidated amount)
|
|
129
|
+
* Row 9+ (index 8+): Data rows
|
|
130
|
+
*/
|
|
131
|
+
function parseIncomeStatementCSV(csvText) {
|
|
132
|
+
const lines = csvText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
133
|
+
const errors = [];
|
|
134
|
+
const rows = [];
|
|
135
|
+
// Month label is on row 4 (0-indexed: 3)
|
|
136
|
+
const monthLabel = lines[3]?.trim() ?? '';
|
|
137
|
+
const period = monthLabelToPeriod(monthLabel);
|
|
138
|
+
if (!period) {
|
|
139
|
+
errors.push(`Could not parse period from row 4: "${monthLabel}"`);
|
|
140
|
+
}
|
|
141
|
+
// Find "Total" column index from header row (row 7, index 6)
|
|
142
|
+
const headerLine = lines[6] ?? '';
|
|
143
|
+
const headers = parseCsvLine(headerLine);
|
|
144
|
+
let totalColIdx = headers.length - 1;
|
|
145
|
+
for (let i = headers.length - 1; i >= 0; i--) {
|
|
146
|
+
if (headers[i].toLowerCase().includes('total')) {
|
|
147
|
+
totalColIdx = i;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Data rows start at index 8 (row 9)
|
|
152
|
+
for (let i = 8; i < lines.length; i++) {
|
|
153
|
+
const line = lines[i];
|
|
154
|
+
if (!line || line.trim() === '')
|
|
155
|
+
continue;
|
|
156
|
+
const cols = parseCsvLine(line);
|
|
157
|
+
if (cols.length === 0)
|
|
158
|
+
continue;
|
|
159
|
+
const acctInfo = extractAccountCode(cols[0]);
|
|
160
|
+
if (!acctInfo)
|
|
161
|
+
continue;
|
|
162
|
+
const totalStr = cols[totalColIdx] ?? '';
|
|
163
|
+
const amount = parseCurrency(totalStr);
|
|
164
|
+
rows.push({
|
|
165
|
+
account_code: acctInfo.account_code,
|
|
166
|
+
netsuite_label: acctInfo.netsuite_label,
|
|
167
|
+
total_amount: amount,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (rows.length === 0) {
|
|
171
|
+
errors.push('No valid account rows found in CSV');
|
|
172
|
+
}
|
|
173
|
+
return { period, monthLabel, rows, errors };
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Core export
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
/**
|
|
179
|
+
* Upload a confirmed income statement CSV into `confirmed_income_statements`.
|
|
180
|
+
*
|
|
181
|
+
* Reads the CSV at `filePath`, parses it using the same logic as the
|
|
182
|
+
* dashboard's income-statement-parser, then upserts all account rows into
|
|
183
|
+
* ReturnPro Supabase (conflict resolution: account_code + period).
|
|
184
|
+
*
|
|
185
|
+
* @param filePath Absolute path to the NetSuite income statement CSV.
|
|
186
|
+
* @param userId User ID to associate with the upload (stored in `uploaded_by` if column exists; otherwise ignored).
|
|
187
|
+
* @param periodOverride Optional period override in "YYYY-MM" format.
|
|
188
|
+
* @returns IncomeStatementResult summary.
|
|
189
|
+
*/
|
|
190
|
+
export async function uploadIncomeStatements(filePath, userId, periodOverride) {
|
|
191
|
+
// 1. Read file
|
|
192
|
+
const csvText = readFileSync(filePath, 'utf-8');
|
|
193
|
+
// 2. Parse CSV
|
|
194
|
+
const parsed = parseIncomeStatementCSV(csvText);
|
|
195
|
+
// 3. Resolve period
|
|
196
|
+
const period = periodOverride ?? parsed.period;
|
|
197
|
+
if (!period) {
|
|
198
|
+
throw new Error(`Could not detect period from CSV. Provide a periodOverride (e.g. "2025-04"). Parse errors: ${parsed.errors.join('; ')}`);
|
|
199
|
+
}
|
|
200
|
+
if (parsed.rows.length === 0) {
|
|
201
|
+
throw new Error(`No valid account rows found in ${filePath}. Parse errors: ${parsed.errors.join('; ')}`);
|
|
202
|
+
}
|
|
203
|
+
// 4. Build insert rows
|
|
204
|
+
const now = new Date().toISOString();
|
|
205
|
+
const insertRows = parsed.rows.map((row) => ({
|
|
206
|
+
account_code: row.account_code,
|
|
207
|
+
netsuite_label: row.netsuite_label,
|
|
208
|
+
period,
|
|
209
|
+
total_amount: row.total_amount,
|
|
210
|
+
source: 'netsuite',
|
|
211
|
+
updated_at: now,
|
|
212
|
+
}));
|
|
213
|
+
// 5. Upsert into confirmed_income_statements
|
|
214
|
+
// Conflict target: (account_code, period) — merge-duplicates via onConflict
|
|
215
|
+
const sb = getSupabase('returnpro');
|
|
216
|
+
const { data, error } = await sb
|
|
217
|
+
.from('confirmed_income_statements')
|
|
218
|
+
.upsert(insertRows, {
|
|
219
|
+
onConflict: 'account_code,period',
|
|
220
|
+
ignoreDuplicates: false,
|
|
221
|
+
})
|
|
222
|
+
.select('id,account_code,total_amount');
|
|
223
|
+
if (error) {
|
|
224
|
+
throw new Error(`Supabase upsert failed: ${error.message}${error.hint ? ` (Hint: ${error.hint})` : ''}`);
|
|
225
|
+
}
|
|
226
|
+
const upserted = data?.length ?? 0;
|
|
227
|
+
const skipped = parsed.rows.length - upserted;
|
|
228
|
+
return {
|
|
229
|
+
period,
|
|
230
|
+
monthLabel: parsed.monthLabel,
|
|
231
|
+
upserted,
|
|
232
|
+
skipped,
|
|
233
|
+
warnings: parsed.errors,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface NetSuiteUploadResult {
|
|
2
|
+
/** Original file name (basename) */
|
|
3
|
+
fileName: string;
|
|
4
|
+
/** ISO timestamp of this upload batch */
|
|
5
|
+
loadedAt: string;
|
|
6
|
+
/** Total rows inserted into stg_financials_raw */
|
|
7
|
+
inserted: number;
|
|
8
|
+
/** Distinct YYYY-MM months present in the inserted rows */
|
|
9
|
+
monthsCovered: string[];
|
|
10
|
+
/** Non-fatal warnings (e.g. missing FK lookups) */
|
|
11
|
+
warnings: string[];
|
|
12
|
+
/** Fatal error message if the upload failed entirely */
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Upload a NetSuite XLSM or staging CSV to stg_financials_raw.
|
|
17
|
+
*
|
|
18
|
+
* Supports two XLSM formats:
|
|
19
|
+
* 1. Single "Data Entry" sheet (wide format — account codes as columns)
|
|
20
|
+
* 2. Multi-sheet (≥3 month-named tabs, each a "Data Entry"-style sheet)
|
|
21
|
+
*
|
|
22
|
+
* For CSVs, expects the staging long format:
|
|
23
|
+
* location, master_program, program_id, date, account_code, amount, mode
|
|
24
|
+
*
|
|
25
|
+
* FK columns (account_id, client_id, master_program_id, program_id_key) are
|
|
26
|
+
* resolved from dim_* tables and stamped on each row before insert.
|
|
27
|
+
*
|
|
28
|
+
* `stg_financials_raw.amount` is stored as TEXT per DB schema.
|
|
29
|
+
*
|
|
30
|
+
* @param filePath Absolute path to .xlsm, .xlsx, or .csv file
|
|
31
|
+
* @param userId User ID to associate with this upload (stored for audit)
|
|
32
|
+
* @param options Optional: `months` array of YYYY-MM strings to filter which
|
|
33
|
+
* month tabs to process (multi-sheet XLSM only)
|
|
34
|
+
*/
|
|
35
|
+
export declare function processNetSuiteUpload(filePath: string, userId: string, options?: {
|
|
36
|
+
months?: string[];
|
|
37
|
+
}): Promise<NetSuiteUploadResult>;
|