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
73 changes: 73 additions & 0 deletions frontend/e2e/diagnostics-checkout-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,85 @@ test('a structured quantity rejection keeps its specific message without a dupli
operation_id: originalOperationId,
outcome: 'not_applied',
});
expect(
supportSubmissions[0].message,
'support ticket contains the informative rejection reason',
).toBe('Invalid order quantity');
expect(supportSubmissions[1]).toEqual(supportSubmissions[0]);
} finally {
await restoreBusiness(page, originalBusiness);
}
});

test('a structured stock rejection displays the specific item name to the cashier and in the support ticket', async ({
page,
}) => {
const originalBusiness = await setBillingType(page, 'postpaid');
try {
await loginAndOpenPos(page);
const supportSubmissions: Array<Record<string, unknown>> = [];
await page.route('**/api/support-ticket', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const payload = route.request().postDataJSON() as Record<string, unknown>;
supportSubmissions.push(payload);
await route.fulfill({
status: 202,
contentType: 'application/json',
body: JSON.stringify({ client_ticket_id: payload.client_ticket_id }),
});
});
let originalOperationId = '';
await page.route('**/api/orders', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const operationId = route.request().headers()['x-operation-id'];
originalOperationId = operationId || '';
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
error: 'Insufficient stock for "Double Cheeseburger"',
reason_code: 'order.stock_insufficient',
operation: 'order.create',
stage: 'validation',
classification: 'expected_rejection',
outcome: 'not_applied',
correlation_id: operationId,
}),
});
});

await addOneProduct(page);
await page.getByRole('button', { name: 'Place Order' }).click();

await expect(
page.getByRole('main').getByText('Insufficient stock for "Double Cheeseburger"'),
).toBeVisible();
await expect(page.getByText('Correct the details, then submit the order again.')).toBeVisible();

const getHelp = page.getByRole('button', { name: 'Get help', exact: true });
await getHelp.click();
await expect.poll(() => supportSubmissions.length).toBe(1);
expect(
supportSubmissions[0].message,
'support ticket receives the item-specific stock rejection message',
).toBe('Insufficient stock for "Double Cheeseburger"');
expect(supportSubmissions[0].correlation_id).toBe(originalOperationId);
expect(supportSubmissions[0].operation_failure).toEqual({
capture_point: 'fc.backend.order.create.stock_validation',
operation_id: originalOperationId,
outcome: 'not_applied',
});
} finally {
await restoreBusiness(page, originalBusiness);
}
});

test('a lost prepaid payment response replays the same saved request after bill recovery', async ({
page,
}) => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/(dashboard)/pos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ export default function POSPage() {
const isAddonOrGeneralValidation =
failure.reasonCode === 'order.addon_selection_required' ||
failure.reasonCode === 'order.addon_unavailable' ||
failure.reasonCode === 'order.stock_insufficient' ||
failure.reasonCode === 'validation.rejected';
const rejectionMessage =
isAddonOrGeneralValidation && failure.serverError
Expand Down
50 changes: 49 additions & 1 deletion frontend/src/lib/order-attempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,50 @@ function plainRecord(value: unknown): value is Record<string, unknown> {
return prototype === Object.prototype || prototype === null;
}

export function sanitizeServerError(rawError: unknown): string | null {
if (typeof rawError !== 'string') return null;
const trimmed = rawError.trim();
if (!trimmed) return null;
const bounded = trimmed.length > 255 ? trimmed.slice(0, 255) : trimmed;
if (
/[\r\n]/.test(bounded) ||
/\bat\s+\S+\s+\(/.test(bounded) ||
/\bat\s+.*:\d+:\d+/.test(bounded)
) {
return null;
}
if (
/(?:sqlite|syntax error|database (?:locked|disk image)|no such (?:table|column)|foreign key constraint|unique constraint|primary key constraint|check constraint|table\s+\w+|column\s+\w+|select\s+.+from|insert\s+into|update\s+.+set|delete\s+from|drop\s+table|alter\s+table)/i.test(
bounded,
)
) {
return null;
}
if (
/^(?:[A-Z][a-zA-Z0-9_]*(?:Error|Exception)|TypeError|RangeError|SyntaxError|ReferenceError|SystemError):/i.test(
bounded,
)
) {
return null;
}
if (
/\b(?:ENOENT|EACCES|EPERM|ECONNREFUSED|ECONNRESET|EADDRINUSE|ETIMEDOUT|EPIPE)\b/i.test(bounded)
) {
return null;
}
if (/(?:^|[\s"'`])(?:(?:\/|[a-zA-Z]:[\\/])\w+|(?:\.\.?[\\/]\w+))/i.test(bounded)) {
return null;
}
if (
/\b(?:bearer\s+[a-zA-Z0-9._-]+|jwt|api[_-]?key|client[_-]?secret|secret[_-]?key|private[_-]?key|password)\b/i.test(
bounded,
)
) {
return null;
}
return bounded;
}

/** Trusts additive server metadata only when the closed tuple and operation reference match. */
export function classifyOrderRequestFailure(
error: unknown,
Expand Down Expand Up @@ -553,6 +597,9 @@ export function classifyOrderRequestFailure(
)
return fallback;

const serverError = sanitizeServerError(payload.error);
const detail = serverError ?? fallback.detail;

return {
...fallback,
code: payload.reason_code,
Expand All @@ -562,7 +609,8 @@ export function classifyOrderRequestFailure(
classification: definition.classification,
correlationId: expected.correlationId,
displayCode: typeof displayCode === 'string' ? displayCode : null,
serverError: typeof payload.error === 'string' ? payload.error : null,
serverError,
detail,
trusted: true,
};
}
8 changes: 3 additions & 5 deletions main/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,9 @@ function moveOrderItemAddonStock(
const pool = db.prepare('SELECT stock_quantity FROM addons WHERE id = ?').get(addon.id) as
| { stock_quantity: number }
| undefined;
throw Object.assign(
new Error(
`Add-on "${addon.name}" is out of stock (requested ${quantity}, available ${Number(pool?.stock_quantity ?? 0)})`,
),
{ statusCode: 400 },
throw new InventoryServiceError(
400,
`Add-on "${addon.name}" is out of stock (requested ${quantity}, available ${Number(pool?.stock_quantity ?? 0)})`,
);
}
}
Expand Down
3 changes: 3 additions & 0 deletions main/services/cloud-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2496,6 +2496,9 @@ export class CloudSyncService {
}>;
if (rows.length === 0) return;

const { isTelemetrySuppressed } = require('./telemetry') as typeof import('./telemetry');
if (isTelemetrySuppressed()) return;

for (const row of rows) {
if (this.cloudDeletionInProgress || this.shutdownRequested) return;
if (!isDiagnosticsConsentEnabled() || !isDiagnosticsTransmissionEnabled()) return;
Expand Down
27 changes: 24 additions & 3 deletions main/services/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,26 @@ export function adjustProductStock(
stockLabel = 'Variant';
stockIdArgs = [variantId, options.productId as string | number];
}
const readStock = db.prepare(`SELECT stock_quantity FROM ${stockTable} WHERE ${stockKey}`);
const current = readStock.get(...stockIdArgs) as { stock_quantity: number | null } | undefined;
const readStock = db.prepare(`SELECT stock_quantity, name FROM ${stockTable} WHERE ${stockKey}`);
const current = readStock.get(...stockIdArgs) as
| { stock_quantity: number | null; name?: string }
| undefined;
let itemName: string | null = null;
if (current?.name) {
const safeName = (name: string) => (name.length > 60 ? `${name.slice(0, 57)}...` : name);
if (variantId && options.productId) {
const productRow = db
.prepare('SELECT name FROM products WHERE id = ?')
.get(options.productId as string | number) as { name?: string } | undefined;
itemName = productRow?.name
? `"${safeName(productRow.name)}" (${safeName(current.name)})`
: `variant "${safeName(current.name)}"`;
} else if (addonId) {
itemName = `add-on "${safeName(current.name)}"`;
} else {
itemName = `"${safeName(current.name)}"`;
}
}
const stockBefore = Number(current?.stock_quantity ?? 0);
const update = db.prepare(`
UPDATE ${stockTable}
Expand Down Expand Up @@ -226,7 +244,10 @@ export function adjustProductStock(
if (!current) {
throw new InventoryServiceError(404, `${stockLabel} not found`);
}
throw new InventoryServiceError(400, 'Insufficient stock');
throw new InventoryServiceError(
400,
itemName ? `Insufficient stock for ${itemName}` : 'Insufficient stock',
);
}

const updatedStock = readStock.get(...stockIdArgs) as { stock_quantity: number };
Expand Down
2 changes: 2 additions & 0 deletions main/services/posthog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export function postHogEnvironment(): PostHogEnvironment | null {
if (process.env.CI) return null;
if (process.env.FLO_MATRIX_OFFLINE === '1') return null;
if (process.env.E2E_TASK_LOCAL_PORTS) return null;
if (process.env.NODE_ENV === 'test') return null;
if (process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK === '1') return null;
if ((process as NodeJS.Process & { type?: string }).type !== 'browser') return null;
if (process.env.FLO_POSTHOG_DEV === '1') return 'development';
return isOfficialReleaseBuild() ? 'production' : null;
Expand Down
33 changes: 31 additions & 2 deletions main/services/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../db';
import { normalizeSupportedCurrencyCode } from '../../shared/currencies';
import { isOperationReference, serializeOperationFailure } from '../lib/operation-failure';
import { getBuildIdentity } from './build-identity';

export const TELEMETRY_URL = 'https://telemetry.flopos.com/collect';

Expand Down Expand Up @@ -125,8 +126,36 @@ export function getInstallSource(): InstallSource {
return linuxPackageFormat();
}

export function isTelemetrySuppressed(): boolean {
if (
process.env.FLO_MATRIX_OFFLINE === '1' ||
Boolean(process.env.E2E_TASK_LOCAL_PORTS) ||
process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK === '1'
) {
return true;
}
if (process.env.FLO_TELEMETRY_DEV === '1') {
return false;
}
if (process.env.NODE_ENV === 'test' || Boolean(process.env.CI)) {
return true;
}
const version = typeof app?.getVersion === 'function' ? app.getVersion() : '';
if (
version === '1.0.0-test' ||
version === 'test' ||
version === 'e2e' ||
/(?:^|[-.])(?:test|e2e)(?:[-.]|$)/i.test(version)
) {
return true;
}
const isDev = !app?.isPackaged || getBuildIdentity() === 'dev';
if (isDev) return true;
return false;
}

function matrixOffline(): boolean {
return process.env.FLO_MATRIX_OFFLINE === '1';
return isTelemetrySuppressed();
}

function trackTelemetry<T>(operation: Promise<T>): Promise<T> {
Expand Down Expand Up @@ -326,7 +355,7 @@ export async function sendAnonymousDiagnosticReport(

try {
const anonId = ensureTelemetryAnonId();
const build = require('./build-identity').getBuildIdentity() as string;
const build = getBuildIdentity();
const response = await fetch(TELEMETRY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@
"test:diagnostics-outbox-bounds": "node tests/run-electron-node-test.cjs tests/diagnostics-outbox-bounds.test.ts",
"test:diagnostic-wire-producer": "node tests/run-electron-node-test.cjs tests/diagnostic-wire-producer.test.ts",
"test:diagnostics-screen": "node tests/run-electron-node-test.cjs tests/diagnostics-screen.test.ts",
"test:diagnostics": "bash tests/run-test.sh npm run test:diagnostics-event && bash tests/run-test.sh npm run test:diagnostic-signature && bash tests/run-test.sh npm run test:diagnostics-outbox-bounds && bash tests/run-test.sh npm run test:diagnostic-wire-producer && bash tests/run-test.sh npm run test:diagnostics-screen && bash tests/run-test.sh npm run test:diagnostics-feature-coverage",
"test:telemetry-leakage-prevention": "node tests/run-electron-node-test.cjs tests/telemetry-leakage-prevention.test.ts",
"test:diagnostics": "bash tests/run-test.sh npm run test:diagnostics-event && bash tests/run-test.sh npm run test:diagnostic-signature && bash tests/run-test.sh npm run test:diagnostics-outbox-bounds && bash tests/run-test.sh npm run test:diagnostic-wire-producer && bash tests/run-test.sh npm run test:diagnostics-screen && bash tests/run-test.sh npm run test:diagnostics-feature-coverage && bash tests/run-test.sh npm run test:telemetry-leakage-prevention",
"test:diagnostics-feature-coverage": "node tests/run-electron-node-test.cjs tests/diagnostics-feature-coverage.test.ts",
"test:customer-display-config": "ts-node --transpile-only -P tests/tsconfig.json tests/customer-display-config.test.ts",
"test:customer-display-settings-route": "node tests/run-electron-node-test.cjs tests/customer-display-settings-route.test.ts",
Expand Down
Loading
Loading