diff --git a/DECISIONS.md b/DECISIONS.md index a1d36050..bbf8799d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1437,5 +1437,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-01] Anon-callable SECURITY DEFINER writes: the guard shape `IF auth.uid() IS NOT NULL AND NOT EXISTS (membership)` is unsafe on its own. The anon JWT carries no `sub` claim, so auth.uid() is NULL for role anon too and the guard short-circuits into the trusted branch. It is defense in depth behind a REVOKE FROM PUBLIC, anon, never a substitute for one. Every new SECURITY DEFINER function ships with that REVOKE; tests/pg/definer-function-grants.pg.test.ts enforces it from a sweep rather than a hand list, because hand-listing is exactly how three guarded numbering RPCs were wrongly declared safe. [2026-09-01] public.create_invoice_with_items(jsonb,jsonb) is revoked, not dropped. It is prod-only, uncalled and already non-functional, so removing it is cleanup rather than security, and the revoke closes the hole in full. An irreversible schema deletion against production belongs in its own reviewable migration. [2026-09-01] SKV connector refresh classification fixed broker-side (skeptic refutation on PR #2103, found independently by two skeptics): the broker's /oauth/token catch-all had collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the DOMINANT refresh outcome, per-flow tokens live 65 min) into the generic 502, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing ops consumed as non-recoverable, crons retrying raw forever. The broker now re-codes those dialects (refresh grant only, never the code exchange where invalid_grant means an expired one-shot code) as 401 CONNECTOR_SKV_REFRESH_DEAD, which the instance maps to SESSION_EXPIRED; the generic 502 remains raw so a transient SKV outage still never re-arms the reconnect banner (#1155). Same pass: the data proxy now forwards WWW-Authenticate + x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; nothing secret rides in them), and the instance's gateway-refusal guidance is connector-aware (a self-host has no SKATTEVERKET_APIGW_CLIENT_ID or Utvecklarportalen access: point at /api/connector/status + support instead). +[2026-09-01] WooCommerce failed orders: excluded 'failed' from order sync + remove-on-transition; kept 'cancelled' importing and 'trash' skip-only: cancelled is a real order some users want visible (asked in user reply), trash can be restored in wp-admin and may mirror a paid event. Removal deletes app-side with freeze guards incl. frozen-refund-child veto (parent_order_id cascades, table has no delete trigger). +[2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents). [2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR). [2026-09-01] EB claim guard round 2 (skeptic re-verify): pending_selection rows are asymmetric, not excluded: their ENABLED accounts still claim (attach-created rows hold offered accounts with no cash rows until saved; excluding them reopened the attach-window double-booking), while their disabled flags stay out of deselection memory (unconfirmed callback output). Both fetchAllRows claim queries order('id'): unordered .range() pagination can silently skip rows at page boundaries, and a skipped row is a missed claim (fail-open). diff --git a/extensions/general/woocommerce/__tests__/api-routes.test.ts b/extensions/general/woocommerce/__tests__/api-routes.test.ts index d12cdc2a..51574ebe 100644 --- a/extensions/general/woocommerce/__tests__/api-routes.test.ts +++ b/extensions/general/woocommerce/__tests__/api-routes.test.ts @@ -279,6 +279,7 @@ describe('woocommerce extension routes', () => { inserted: 4, updated: 0, unchanged: 0, + removed: 0, frozenFlagged: 0, crossMarked: 0, errors: 0, diff --git a/extensions/general/woocommerce/__tests__/order-sync.test.ts b/extensions/general/woocommerce/__tests__/order-sync.test.ts index c0dd4114..6b640f22 100644 --- a/extensions/general/woocommerce/__tests__/order-sync.test.ts +++ b/extensions/general/woocommerce/__tests__/order-sync.test.ts @@ -14,9 +14,10 @@ vi.mock('../lib/api-client', () => ({ vi.mock('@/lib/webshop-orders/ingest', () => ({ upsertWebshopOrders: vi.fn(), + removeWebshopOrders: vi.fn(), })) -import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest' +import { removeWebshopOrders, upsertWebshopOrders } from '@/lib/webshop-orders/ingest' import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types' import { encryptCredential } from '../lib/credentials' import { @@ -28,6 +29,7 @@ import { mapRefundToWebshopRow, orderImports, orderIsPaid, + orderRemoves, syncWooCommerceOrders, wooOrderExternalId, wooRefundExternalId, @@ -146,6 +148,7 @@ beforeEach(() => { listOrdersPage.mockResolvedValue([]) listOrderRefunds.mockResolvedValue([]) vi.mocked(upsertWebshopOrders).mockResolvedValue({ ...emptyUpsertResult }) + vi.mocked(removeWebshopOrders).mockResolvedValue({ removed: 0, errors: 0 }) }) describe('frozen external_id formats', () => { @@ -176,12 +179,22 @@ describe('frozen external_id formats', () => { }) }) -describe('orderImports / orderIsPaid', () => { - it('every non-trash status imports, paid or not', () => { +describe('orderImports / orderRemoves / orderIsPaid', () => { + it('every status except trash and failed imports, paid or not', () => { expect(orderImports(makeOrder())).toBe(true) expect(orderImports(makeOrder({ status: 'pending', date_paid_gmt: null }))).toBe(true) expect(orderImports(makeOrder({ status: 'refunded' }))).toBe(true) expect(orderImports(makeOrder({ status: 'trash' }))).toBe(false) + expect(orderImports(makeOrder({ status: 'failed', date_paid_gmt: null }))).toBe(false) + }) + + it('only unpaid failed orders trigger removal of an existing row', () => { + expect(orderRemoves(makeOrder({ status: 'failed', date_paid_gmt: null }))).toBe(true) + // A failed order that was at some point paid keeps its row: money moved. + expect(orderRemoves(makeOrder({ status: 'failed' }))).toBe(false) + expect(orderRemoves(makeOrder({ status: 'trash', date_paid_gmt: null }))).toBe(false) + expect(orderRemoves(makeOrder())).toBe(false) + expect(orderRemoves(makeOrder({ status: 'cancelled' }))).toBe(false) }) it('is_paid follows date_paid', () => { @@ -563,6 +576,57 @@ describe('syncWooCommerceOrders', () => { expect((rows as WebshopOrderUpsert[])[0]).toMatchObject({ is_paid: false }) }) + it('removes the existing row of a failed order instead of importing it', async () => { + const { client, updates } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([ + makeOrder({ + status: 'failed', + date_paid_gmt: null, + // A stray refunds stub must not trigger a refund fetch for a + // non-importing order. + refunds: [{ id: 9, reason: '', total: '-100.00' }], + }), + ]) + vi.mocked(removeWebshopOrders).mockResolvedValueOnce({ removed: 1, errors: 0 }) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary).toMatchObject({ fetched: 1, inserted: 0, removed: 1, errors: 0 }) + expect(upsertWebshopOrders).not.toHaveBeenCalled() + expect(listOrderRefunds).not.toHaveBeenCalled() + expect(removeWebshopOrders).toHaveBeenCalledWith(client, 'company-1', [ + 'woo_shop.example.se_order_1042', + ]) + // The removal is complete work: the cursor advances normally. + const cursors = cursorUpdates(updates) + expect(cursors).toHaveLength(1) + expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:05:00.000Z') + }) + + it('neither imports nor removes a paid order the store reports as failed', async () => { + const { client } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([makeOrder({ status: 'failed' })]) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary).toMatchObject({ fetched: 1, inserted: 0, removed: 0, errors: 0 }) + expect(upsertWebshopOrders).not.toHaveBeenCalled() + expect(removeWebshopOrders).not.toHaveBeenCalled() + }) + + it('holds the cursor below a page whose removal reported errors', async () => { + const { client, updates } = makeSupabaseMock() + listOrdersPage.mockResolvedValueOnce([makeOrder({ status: 'failed', date_paid_gmt: null })]) + vi.mocked(removeWebshopOrders).mockResolvedValueOnce({ removed: 0, errors: 1 }) + + const summary = await syncWooCommerceOrders(client, makeConnection()) + + expect(summary.errors).toBe(1) + const cursors = cursorUpdates(updates) + expect(cursors).toHaveLength(1) + expect(cursors[0].values.last_order_synced_at).toBe('2026-08-01T09:04:59.000Z') + }) + it('holds the cursor below an order whose refund fetch failed', async () => { const { client, updates } = makeSupabaseMock() const order = makeOrder({ refunds: [{ id: 77, reason: '', total: '-250.00' }] }) diff --git a/extensions/general/woocommerce/lib/order-sync.ts b/extensions/general/woocommerce/lib/order-sync.ts index e2d16a6a..f502394b 100644 --- a/extensions/general/woocommerce/lib/order-sync.ts +++ b/extensions/general/woocommerce/lib/order-sync.ts @@ -1,5 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' -import { upsertWebshopOrders } from '@/lib/webshop-orders/ingest' +import { removeWebshopOrders, upsertWebshopOrders } from '@/lib/webshop-orders/ingest' import type { WebshopOrderUpsert } from '@/lib/webshop-orders/types' import { createLogger, type Logger } from '@/lib/logger' import { roundOre as round } from '@/lib/money' @@ -21,8 +21,9 @@ const defaultLog = createLogger('woocommerce/order-sync') * public.webshop_orders (the Orders page), replacing the earlier * transactions-inbox feed. * - * Every non-trash order imports (including unpaid ones: the Orders page shows - * order state and the invoice flow needs pre-payment orders), carrying the + * Every order imports except trash and failed (including unpaid ones: the + * Orders page shows order state and the invoice flow needs pre-payment + * orders), carrying the * full booking underlag the wc/v3 payload already contains: customer billing * snapshot, payment method, per-rate VAT breakdown and line items. Refunds * are separate negative rows parented to their order. Nothing here books @@ -113,6 +114,8 @@ export interface WooCommerceSyncSummary { updated: number /** Re-polled rows with nothing new. */ unchanged: number + /** Rows deleted because the store now reports the order as failed. */ + removed: number /** Booked rows whose financials drifted remotely (flagged, not touched). */ frozenFlagged: number /** Rows linked to a row the retired transactions feed already imported. */ @@ -157,9 +160,26 @@ export function orderIsPaid(order: Pick): boolean { return Boolean(order.date_paid_gmt) } -/** Every non-trash order imports; trash is the store's recycle bin. */ +/** + * Which orders exist in the feed. Trash is the store's recycle bin; failed + * is a checkout whose payment never went through, so it carries no money + * event (user report: failed attempts flooded the Orders page as permanently + * unbookable "Ej betald" rows). + */ export function orderImports(order: Pick): boolean { - return order.status !== 'trash' + return order.status !== 'trash' && order.status !== 'failed' +} + +/** + * Orders whose already-imported rows should be REMOVED on re-poll: a pending + * order that transitions to failed would otherwise sit stale forever. A + * failed order that has a date_paid is NOT removed (skeptic finding): money + * moved at some point (gateway void, admin bulk-edit mistake), and deleting + * the row would hide a possibly real money event, the same reason trash + * keeps its long-standing skip-only semantics. + */ +export function orderRemoves(order: Pick): boolean { + return order.status === 'failed' && !orderIsPaid(order) } /** @@ -467,6 +487,8 @@ export function mapRefundToWebshopRow( interface PageRowsOutcome { rows: WebshopOrderUpsert[] + /** external_ids of orders whose existing rows should be removed (failed). */ + removalExternalIds: string[] /** * date_modified (ms) of every order whose refund rows are incomplete this * run (fetch failed or skipped on deadline). The cursor must not advance @@ -486,14 +508,28 @@ async function buildPageRows( log: Logger, deadlineMs?: number, ): Promise { - const outcome: PageRowsOutcome = { rows: [], incompleteModifiedMs: [], hitDeadline: false } + const outcome: PageRowsOutcome = { + rows: [], + removalExternalIds: [], + incompleteModifiedMs: [], + hitDeadline: false, + } for (const order of orders) { + if (orderRemoves(order)) { + outcome.removalExternalIds.push(wooOrderExternalId(storeScope, order.id)) + } + // Non-importing orders contribute nothing else; skipping here also keeps + // their refunds out of the feed. Unreachable for trash in practice (the + // list call asks for status=any, which excludes trash), and a failed + // order's refund would parent to a row being removed: an unexplainable + // negative either way. + if (!orderImports(order)) continue // A corrupt total is counted and logged, never silently identical to a // zero-total order. Deliberately NOT held via the cursor: a permanently // corrupt total would stall the whole feed forever, where a skipped row // plus a loud error can be followed up. - if (orderImports(order) && orderAmountUnparseable(order)) { + if (orderAmountUnparseable(order)) { summary.errors += 1 log.warn('unparseable order total; row skipped', { orderId: order.id, @@ -572,6 +608,7 @@ export async function syncWooCommerceOrders( inserted: 0, updated: 0, unchanged: 0, + removed: 0, frozenFlagged: 0, crossMarked: 0, errors: 0, @@ -648,6 +685,20 @@ export async function syncWooCommerceOrders( failureFloorMs = Math.min(failureFloorMs, firstMs - 1000) } } + if (page.removalExternalIds.length > 0) { + const removal = await removeWebshopOrders( + supabase, + connection.company_id, + page.removalExternalIds, + ) + summary.removed += removal.removed + summary.errors += removal.errors + if (removal.errors > 0) { + // Same retry contract as failed upserts: hold the cursor so the + // next run re-lists this page and retries the removal. + failureFloorMs = Math.min(failureFloorMs, firstMs - 1000) + } + } for (const ms of page.incompleteModifiedMs) { failureFloorMs = Math.min(failureFloorMs, ms - 1000) } diff --git a/lib/webshop-orders/__tests__/ingest.test.ts b/lib/webshop-orders/__tests__/ingest.test.ts index 24159859..0fae1d19 100644 --- a/lib/webshop-orders/__tests__/ingest.test.ts +++ b/lib/webshop-orders/__tests__/ingest.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { createQueuedMockSupabase } from '@/tests/helpers' -import { upsertWebshopOrders } from '../ingest' +import { removeWebshopOrders, upsertWebshopOrders } from '../ingest' import type { WebshopOrderUpsert } from '../types' import type { SupabaseClient } from '@supabase/supabase-js' @@ -313,3 +313,100 @@ describe('upsertWebshopOrders', () => { expect(result.firstError?.message).toBe('boom') }) }) + +describe('removeWebshopOrders', () => { + let mock: ReturnType + const supabase = () => mock.supabase as unknown as SupabaseClient + + beforeEach(() => { + vi.clearAllMocks() + mock = createQueuedMockSupabase() + }) + + it('deletes unfrozen rows and reports the count', async () => { + mock.enqueueMany([ + { data: [{ id: 'row-1' }, { id: 'row-2' }] }, // unfrozen candidates + { data: [] }, // no frozen refund children + { data: [{ id: 'row-1' }, { id: 'row-2' }] }, // delete + ]) + + const result = await removeWebshopOrders(supabase(), COMPANY, [ + 'woo_butik.example.se_order_1001', + 'woo_butik.example.se_order_1002', + ]) + + expect(result).toEqual({ removed: 2, errors: 0 }) + expect(mock.findCall('webshop_orders', 'delete')).toBeDefined() + // The freeze guards run on the candidate select AND are repeated on the + // delete statement itself: a row frozen between the two round trips must + // survive (TOCTOU skeptic finding). + const guardColumns = [ + 'journal_entry_id', + 'invoice_id', + 'manually_booked_at', + 'legacy_transaction_id', + ] + expect(mock.findCalls('webshop_orders', 'is').map((args) => args[0])).toEqual([ + ...guardColumns, + ...guardColumns, + ]) + // Paid rows are never deleted, on both statements. + expect( + mock.findCalls('webshop_orders', 'eq').filter((args) => args[0] === 'is_paid'), + ).toEqual([ + ['is_paid', false], + ['is_paid', false], + ]) + }) + + it('does nothing when no unfrozen row matches', async () => { + mock.enqueueMany([{ data: [] }]) + + const result = await removeWebshopOrders(supabase(), COMPANY, [ + 'woo_butik.example.se_order_1001', + ]) + + expect(result).toEqual({ removed: 0, errors: 0 }) + expect(mock.findCall('webshop_orders', 'delete')).toBeUndefined() + }) + + it('spares a parent whose refund child is frozen (delete would cascade)', async () => { + mock.enqueueMany([ + { data: [{ id: 'row-1' }, { id: 'row-2' }] }, + { data: [{ parent_order_id: 'row-1' }] }, // row-1 has a booked refund + { data: [{ id: 'row-2' }] }, + ]) + + const result = await removeWebshopOrders(supabase(), COMPANY, [ + 'woo_butik.example.se_order_1001', + 'woo_butik.example.se_order_1002', + ]) + + expect(result).toEqual({ removed: 1, errors: 0 }) + const deleteIn = mock + .findCalls('webshop_orders', 'in') + .find((args) => args[0] === 'id') + expect(deleteIn?.[1]).toEqual(['row-2']) + }) + + it('surfaces delete errors without throwing', async () => { + mock.enqueueMany([ + { data: [{ id: 'row-1' }] }, + { data: [] }, + { data: null, error: { message: 'boom', code: '500' } }, + ]) + + const result = await removeWebshopOrders(supabase(), COMPANY, [ + 'woo_butik.example.se_order_1001', + ]) + + expect(result.removed).toBe(0) + expect(result.errors).toBe(1) + expect(result.firstError?.message).toBe('boom') + }) + + it('returns immediately on an empty id list', async () => { + const result = await removeWebshopOrders(supabase(), COMPANY, []) + expect(result).toEqual({ removed: 0, errors: 0 }) + }) +}) diff --git a/lib/webshop-orders/ingest.ts b/lib/webshop-orders/ingest.ts index 5e89e542..9096676f 100644 --- a/lib/webshop-orders/ingest.ts +++ b/lib/webshop-orders/ingest.ts @@ -3,7 +3,11 @@ import { fetchExchangeRate } from '@/lib/currency/riksbanken' import { roundOre as round } from '@/lib/money' import { createLogger } from '@/lib/logger' import type { Currency, WebshopOrder } from '@/types' -import type { WebshopOrderUpsert, WebshopOrderUpsertResult } from './types' +import type { + WebshopOrderRemovalResult, + WebshopOrderUpsert, + WebshopOrderUpsertResult, +} from './types' const log = createLogger('webshop-orders/ingest') @@ -452,3 +456,115 @@ export async function upsertWebshopOrders( return result } + +/** + * Delete rows the store no longer considers money events (WooCommerce + * 'failed' payment attempts: user report, failed checkouts sat forever as + * unbookable "Ej betald" rows). The freeze boundary applies to deletes as it + * does to updates, application-side here because the table has no + * delete-protection trigger: + * + * - a frozen row (booked / invoiced / manually marked) is never deleted; + * - a paid row is never deleted: money moved at some point, and a paid row + * is also the only kind that can have refund children (parent_order_id + * cascades, so deleting one could take a booked refund row with it); + * - a cross-marked row (legacy_transaction_id) is never deleted: the order + * may be booked via the retired transactions feed without any freeze + * column set on this row; + * - every guard is repeated ON THE DELETE STATEMENT itself, not only on the + * candidate select (skeptic finding): a row booked between the select and + * the delete must survive, exactly like the conditional booking claim in + * book-order.ts. The frozen-child veto select below is defense in depth + * on top of the is_paid guard. + */ +export async function removeWebshopOrders( + supabase: SupabaseClient, + companyId: string, + externalIds: string[], +): Promise { + const result: WebshopOrderRemovalResult = { removed: 0, errors: 0 } + if (externalIds.length === 0) return result + + const recordError = (err: unknown) => { + result.errors += 1 + if (!result.firstError) { + const anyErr = err as { message?: string; code?: string | null } + result.firstError = { + message: anyErr?.message ?? String(err), + code: anyErr?.code ?? null, + } + } + } + + for (const batch of chunk(externalIds, CHUNK_SIZE)) { + const { data: candidates, error: candidateError } = await supabase + .from('webshop_orders') + .select('id') + .eq('company_id', companyId) + .in('external_id', batch) + .eq('is_paid', false) + .is('journal_entry_id', null) + .is('invoice_id', null) + .is('manually_booked_at', null) + .is('legacy_transaction_id', null) + if (candidateError) { + recordError(candidateError) + continue + } + const candidateIds = ((candidates ?? []) as Array<{ id: string }>).map((r) => r.id) + if (candidateIds.length === 0) continue + + const { data: frozenChildren, error: childError } = await supabase + .from('webshop_orders') + .select('parent_order_id') + .eq('company_id', companyId) + .in('parent_order_id', candidateIds) + .or('journal_entry_id.not.is.null,invoice_id.not.is.null,manually_booked_at.not.is.null') + if (childError) { + recordError(childError) + continue + } + const vetoedParents = new Set( + ((frozenChildren ?? []) as Array<{ parent_order_id: string }>).map( + (r) => r.parent_order_id, + ), + ) + const deletableIds = candidateIds.filter((id) => !vetoedParents.has(id)) + if (deletableIds.length === 0) continue + + const { data: deletedRows, error: deleteError } = await supabase + .from('webshop_orders') + .delete() + .eq('company_id', companyId) + .in('id', deletableIds) + .eq('is_paid', false) + .is('journal_entry_id', null) + .is('invoice_id', null) + .is('manually_booked_at', null) + .is('legacy_transaction_id', null) + .select('id') + if (deleteError) { + recordError(deleteError) + log.warn('webshop order removal batch failed', { + companyId, + batchSize: deletableIds.length, + code: (deleteError as { code?: string }).code, + }) + } else { + const deletedIds = ((deletedRows ?? []) as Array<{ id: string }>).map((r) => r.id) + result.removed += deletedIds.length + if (deletedIds.length > 0) { + // Hard delete of financial staging rows: the log is the only record + // of what was removed and by which process (compliance finding, ISO + // A.8.10), since pre-bokföring rows carry no behandlingshistorik. + log.info('webshop order rows removed (store reports order failed)', { + companyId, + deletedIds, + requestedExternalIds: batch, + }) + } + } + } + + return result +} diff --git a/lib/webshop-orders/types.ts b/lib/webshop-orders/types.ts index c6ddf6f9..7263f5bb 100644 --- a/lib/webshop-orders/types.ts +++ b/lib/webshop-orders/types.ts @@ -46,6 +46,14 @@ export interface WebshopOrderUpsert { refunded_total: number } +export interface WebshopOrderRemovalResult { + /** Rows deleted (unfrozen order rows; unfrozen refund children cascade). */ + removed: number + errors: number + /** First error encountered, surfaced for the sync summary/logs. */ + firstError?: { message: string; code?: string | null } +} + export interface WebshopOrderUpsertResult { inserted: number updated: number