Skip to content
Merged
Show file tree
Hide file tree
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
16 changes: 9 additions & 7 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,15 +524,17 @@ Router: `main/routes/print-templates.ts`. Full path: `/api/print-templates`.

Router: `main/routes/menu-csv.ts`. Full path: `/api/menu-csv`.

Category, product, and add-on exports include `flo_csv_encoding=quote-v1` for reversible formula protection; see [product invariants](product-invariants.md#catalog-csv-encoding-and-legacy-apostrophe-ambiguity) for marked, unmarked legacy, and unsupported encoding rules.

| Method | Path | Authorization | Parameters | Response |
| --- | --- | --- | --- | --- |
| `GET` | `/template/:type` | `ROLE_ACCESS.ownerManager` | path: `type` | CSV header template for `categories`, `products`, or `addons`. |
| `GET` | `/export/categories` | `ROLE_ACCESS.ownerManager` | none | `text/csv` attachment `categories-export.csv`. |
| `GET` | `/export/products` | `ROLE_ACCESS.ownerManager` | none | `text/csv` attachment `products-export.csv`. |
| `GET` | `/export/addons` | `ROLE_ACCESS.ownerManager` | none | `text/csv` attachment `addons-export.csv`. |
| `POST` | `/import/categories` | `ROLE_ACCESS.ownerManager` | body: `csv` | Body `csv` is the raw file text. Per-row validation errors are reported in the response. |
| `POST` | `/import/products` | `ROLE_ACCESS.ownerManager` | body: `csv` | Body `csv` is the raw file text. A supplied `id` matching a non-deleted product updates it; a missing or unrecognized `id` creates a product with a generated id unless the name and category already exist, in which case the row is skipped. Returns `created`, `updated`, `reactivated`, `skipped`, and `failed` counts plus per-row `errors`. |
| `POST` | `/import/addons` | `ROLE_ACCESS.ownerManager` | body: `csv` | - |
| `GET` | `/template/:type` | `catalog.import-export` | path: `type` | CSV header template for `categories`, `products`, or `addons`. |
| `GET` | `/export/categories` | `catalog.import-export` | none | `text/csv` attachment `categories-export.csv` with `flo_csv_encoding=quote-v1`. |
| `GET` | `/export/products` | `catalog.import-export` | none | `text/csv` attachment `products-export.csv` with `flo_csv_encoding=quote-v1`. |
| `GET` | `/export/addons` | `catalog.import-export` | none | `text/csv` attachment `addons-export.csv` with `flo_csv_encoding=quote-v1`. |
| `POST` | `/import/categories` | `catalog.import-export` | body: `csv` | Body `csv` is the raw file text. Decodes `quote-v1` rows, preserves unmarked category names, and reports per-row validation errors in the response. |
| `POST` | `/import/products` | `catalog.import-export` | body: `csv` | Body `csv` is the raw file text. A supplied `id` matching a non-deleted product updates it; a missing or unrecognized `id` creates a product with a generated id unless the name and category already exist, in which case the row is skipped. Decodes `quote-v1` rows and ID-proven legacy product fields. Returns `created`, `updated`, `reactivated`, `skipped`, and `failed` counts plus per-row `errors`. |
| `POST` | `/import/addons` | `catalog.import-export` | body: `csv` | Body `csv` is the raw file text. Decodes `quote-v1` rows and ID-proven legacy add-on fields, and reports counts plus per-row `errors`. |

### Settings

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/daily-sales-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ day as a single XLSX workbook or a two-file CSV pair.
|-------|------|----------------|
| Dataset builder | `main/services/daily-sales-export.ts` | Single source of truth for all accounting: `buildDailySalesExportDataset`, `dailySalesSummaryMetricRows`, `DAILY_SALES_ITEM_COLUMNS`. |
| Serializers | `main/services/daily-sales-export-files.ts` | Encode only - no accounting math. `serializeDailySalesExportXlsx`, `serializeDailySalesExportCsv`, `dailySalesExportFilename`. |
| Shared CSV helper | `main/lib/csv.ts` | `toCsvRow` with CWE-1236 formula neutralization (leading quote on `= + - @` non-numerics). Also used by `main/routes/menu-csv.ts`. |
| Shared CSV helper | `main/lib/csv.ts` | `toCsvRow` with CWE-1236 formula neutralization (leading quote on `= + - @` non-numerics); `main/routes/menu-csv.ts` wraps it via `toQuoteV1CsvRow` (see [product invariants](product-invariants.md#catalog-csv-encoding-and-legacy-apostrophe-ambiguity)). |
| Endpoint | `main/routes/reports.ts` | Validation, serialization, headers, error mapping (400 / 401 / 403 / 409 / 500). |
| Frontend | `frontend/src/app/(dashboard)/dashboard/page.tsx` | Blob download only; two buttons shown when `periodMode === 'day'`. |

Expand Down
44 changes: 44 additions & 0 deletions docs/reference/product-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,47 @@ address that looks complete is worse than one holding a marked one.
product migration because store-local overrides live in sparse tables. It would also have to
address `customers.view`, which is the wider exposure. Raise it as its own issue rather than
narrowing one print route.

---

## Catalog CSV encoding and legacy apostrophe ambiguity

**Rule:** Catalog CSV exports (`categories`, `products`, and `addons`) carry a `flo_csv_encoding`
column set to `quote-v1`. Under `quote-v1`, an original leading apostrophe (`'`) on a non-numeric
field is doubled before `toCsvRow` applies spreadsheet formula protection (prefixing `'` onto
non-numeric values starting with `=`, `+`, `-`, `@`, or leading whitespace before those
characters).

1. **Marked imports (`flo_csv_encoding=quote-v1`).** The importer removes the single apostrophe
added by `quote-v1` encoding (either from doubling an original leading `'` or from formula
protection), making repeated export and import cycles lossless across formula-leading values,
leading whitespace before formula triggers, and genuine leading apostrophes.
2. **Unmarked legacy imports (missing or empty `flo_csv_encoding`).** The `quote-v1` decoding rule
is never applied to unmarked rows. On product or add-on rows carrying a stable ID that matches an
existing record, a leading apostrophe is removed only when comparison with that exact existing
record's stored value proves the apostrophe was added by formula protection. All other unmarked
values - including category-only rows without proof of identity, rows without a matching ID, and
existing records whose stored values already begin with `'` - are preserved as-is.
3. **Unsupported markers.** Any non-empty `flo_csv_encoding` value other than `quote-v1` is rejected
as a per-row import validation error.

**Reason:** Pre-`quote-v1` exports prefixed formula-triggering strings with `'` without escaping
genuine leading apostrophes, making `'=foo` in an unmarked file ambiguous between an escaped formula
and a literal apostrophe. Stripping leading apostrophes unconditionally on unmarked files would
corrupt genuine apostrophes, while leaving new exports unmarked would mutate formula-leading names
and spawn duplicate categories and add-ons on round-trip imports.

**Enforced by:** `toQuoteV1CsvRow()`, `encodeQuoteV1Field()`, `decodeQuoteV1Field()`, and
`decodeLegacyProvenField()` in [`main/lib/csv.ts`](../../main/lib/csv.ts), and the export and import
handlers in [`main/routes/menu-csv.ts`](../../main/routes/menu-csv.ts). Other CSV exporters continue
using `toCsvRow()` directly.

**How to verify:**

```sh
npm run test:issue-248-csv
```

**Change policy:** changing the `quote-v1` encoding rules or stripping unmarked apostrophes without
existing-record proof requires an explicit product decision, because either change can silently
rename catalog items or create duplicate categories and add-ons during CSV imports.
46 changes: 45 additions & 1 deletion main/lib/csv.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export const FLO_CSV_ENCODING_COLUMN = 'flo_csv_encoding';
export const FLO_CSV_ENCODING_QUOTE_V1 = 'quote-v1';

const FORMULA_TRIGGER_PREFIX = /^\s*[=+\-@]/;
const QUOTE_V1_ADDED_APOSTROPHE = /^'(?:'|\s*[=+\-@])/;

/** CSV field serializer with spreadsheet formula neutralization (CWE-1236). */
export function toCsvRow(fields: (string | number | null | undefined)[]): string {
return fields
Expand All @@ -7,7 +13,7 @@ export function toCsvRow(fields: (string | number | null | undefined)[]): string
// with a leading quote. Leading whitespace counts as part of the prefix:
// importers strip it before deciding the cell is a formula, so "\t=1+1"
// evaluates just like "=1+1" (CWE-1236).
if (typeof f !== 'number' && /^\s*[=+\-@]/.test(s)) {
if (typeof f !== 'number' && FORMULA_TRIGGER_PREFIX.test(s)) {
s = "'" + s;
}
return s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')
Expand All @@ -16,3 +22,41 @@ export function toCsvRow(fields: (string | number | null | undefined)[]): string
})
.join(',');
}

/** Doubles an original leading apostrophe before `toCsvRow` formula neutralization. */
export function encodeQuoteV1Field(
field: string | number | null | undefined,
): string | number | null | undefined {
if (typeof field === 'number' || field === null || field === undefined) {
return field;
}
const s = String(field);
return s.startsWith("'") ? `'${s}` : s;
}

/** Serializes a CSV row using reversible `quote-v1` escaping plus spreadsheet formula neutralization. */
export function toQuoteV1CsvRow(fields: (string | number | null | undefined)[]): string {
return toCsvRow(fields.map(encodeQuoteV1Field));
}

/** Removes the single apostrophe added by `quote-v1` encoding. */
export function decodeQuoteV1Field(value: string): string {
return QUOTE_V1_ADDED_APOSTROPHE.test(value) ? value.slice(1) : value;
}

/**
* Decodes an unmarked legacy CSV field only when comparison with an existing
* record's stored value proves the leading apostrophe was added by formula neutralization.
*/
export function decodeLegacyProvenField(
csvValue: string,
storedValue: string | null | undefined,
): string {
if (!csvValue || typeof storedValue !== 'string' || !storedValue) {
return csvValue;
}
if (FORMULA_TRIGGER_PREFIX.test(storedValue) && csvValue === `'${storedValue}`.trim()) {
return storedValue;
}
return csvValue;
}
Loading
Loading