Skip to main content

Overview

apps/finance and the Finance experience inside apps/web are paired Finance hosts. They must stay 1:1 for product features, data behavior, mutations, permissions, route behavior, and user-facing workflow updates. Canonical workspace routes include:
  • /{wsId}
  • /{wsId}/transactions
  • /{wsId}/wallets
  • /{wsId}/invoices
  • /{wsId}/categories
  • /{wsId}/tags
  • /{wsId}/recurring
  • /{wsId}/budgets
  • /{wsId}/analytics
  • /{wsId}/debts
apps/web /{wsId}/finance/* routes render the same shared Finance product surface inside the normal platform dashboard shell. apps/finance keeps the shorter standalone route shape without the /finance segment. Use route prefixes instead of forks:
  • apps/web: financePrefix="/finance" and FinanceRouteProvider prefix="/finance".
  • apps/finance: financePrefix="" and FinanceRouteProvider prefix="".
Legacy nested category paths, such as /{wsId}/finance/transactions/categories, should redirect inside apps/web to /{wsId}/finance/categories while preserving query strings.

API Ownership

Native Finance checkpoint creation, editing and batch creation serialize the selected instant in UTC, including its timezone, so server parsing cannot shift the user’s local time. Batch checkpoint responses retain their authoritative rows; /wallets/checkpoints is a collection operation, not a wallet detail mutation in the Inventory dependency scheduler. Ordinary wallet writes keep their existing durable behavior. Mobile error handling accepts both issue arrays and flattened validation objects from the shared Finance handlers. Validation and permission failures retain their HTTP status and readable message rather than becoming Dart type errors. These rules apply to the native client without changing server permissions or web behavior. Regression evidence is in finance_http_contract_test.dart, api_client_error_contract_test.dart and offline_inventory_mutation_test.dart.
  • apps/finance owns the standalone workspace shell, Finance UI route wrappers, local /verify-token handoff, host-local auth/session routes, and build-info.
  • apps/web owns platform-shell Finance route wrappers under /{wsId}/finance/*.
  • apps/finance owns the hard-cutover product handlers for transactions and categories, wallets and checkpoints, budgets, debts, recurring transactions, invoices, charts and overview reporting, and Inventory reconciliation. It also has local supporting handlers for promotions, inventory products, settings, linked products/promotions, and workspace/user lookup needs.
  • The local recurring, invoice, and wallet examples include /api/v1/workspaces/:wsId/finance/recurring-transactions, /api/v1/workspaces/:wsId/finance/invoices, and /api/workspaces/:wsId/wallets. Exact local handlers win before the /api/:path* fallback rewrite is considered.
  • Deliberately central exceptions remain in apps/web: the legacy /api/v1/workspaces/:wsId/wallets list/create route, wallet-role whitelist routes, shared workspace storage routes used by transaction attachments, and the Finance exchange-rate cron. Unmatched requests reach Web through the fallback rewrite.
  • Keep shared callers on @tuturuuu/internal-api. Finance-owned helpers use the Finance API base URL for server calls, while helpers for the deliberate Web exceptions remain on the central API origin; browser calls stay same-origin.
  • Finance-local protected handlers accept the Finance app-session actor and enforce route-level workspace and product permissions. Forwarded Web exceptions require the coordinated Web-issued session and retain their own authorization checks.
  • Finance transaction attachment reads must authorize through the same authenticated transaction visibility path as normal transaction reads. Do not grant storage list, metadata, or signed-read URL access from coarse view_transactions or update_transactions permissions alone; reuse get_wallet_transactions_with_permissions with p_transaction_ids so wallet whitelists, viewing windows, and granular income/expense permissions stay aligned.
  • Finance transaction attachment uploads have server-side limits: 10 files per transaction and 50 MB per file. The signed-upload route must reject over-limit declared sizes before issuing a URL, and finalize must inspect the actual stored object size/count and delete over-limit uploads before any follow-up processing.
  • Finance transaction type filters must not classify confidential amount signs for callers without view_confidential_amount. RPCs that accept p_transaction_type should apply income/expense predicates only when the row is non-confidential or the caller can view confidential amounts; otherwise typed filters should omit those redacted rows instead of using the raw amount sign.
  • Finance invoice customer IDs must be validated against workspace_users with the route workspace before insert, and admin-backed invoice reads should resolve customer display fields with an explicit ws_id filter instead of a nested service-role join on customer_id.
When adding new Finance behavior, update the shared Finance UI/helper first, then wire both host wrappers in the same change. Add or extend the route in apps/finance when it belongs to a hard-cutover family; preserve the explicit Web exceptions until their dependent platform callers migrate. Expose shared access in packages/internal-api and consume it through TanStack Query or shared server components.

Wallet checkpoint reconciliation submission

The shared Finance checkpoint adjustment dialog submits the category and annotation visible when the user presses Reconcile. The configured reconciliation category is selected after its options load; choosing no category still submits no category. Edits made while an already-started request is being scheduled must not change that request’s payload or workspace, wallet and checkpoint identity. Completion invalidates the submitted target; a dialog that has moved to another target is not closed by the old request. The server continues to recompute the adjustment amount from the checkpoint rather than trusting a client preview. This applies to shared Web and satellite Finance dialogs. Regression: wallet-checkpoints.test.tsx covers configured defaults, explicit clearing, a subsequent edit before mutation execution, and retargeting before completion.

Inventory sales reconciliation

Finance owns the Inventory reconciliation inbox and its protected API under /api/workspaces/:wsId/finance/inventory-reconciliation. Access to pending entries, provider mappings, provider history synchronization, manual adjustments, and bulk link/unlink actions requires manage_finance. Every provider-confirmed Polar, Square POS, or Square Terminal event is first stored as an immutable private.inventory_finance_entries source row. The source row affects balances only after an atomic database RPC links it to a currency-compatible wallet transaction. Missing wallets stay pending and are never included in income, wallet, or net-total calculations. Provider-and-currency mappings take precedence over the Inventory revenue wallet fallback and the Finance default wallet. A wallet must use the entry currency. Category resolution uses the unanimous product category first, then the provider mapping, the Inventory default, and finally uncategorized. Deleting or explicitly unlinking a provider transaction removes only the ledger row and returns the immutable source entry to pending. Provider, reference, signed amount, and occurrence date remain provider-controlled; wallet, category, tags, description, and confidentiality remain editable. For rollout and recovery:
  1. Deploy the database migration before Inventory, Finance, or shared-package consumers.
  2. Verify historical completed provider sales appear in the pending inbox. The migration never auto-posts unmatched historical sales.
  3. Configure provider and currency mappings, inspect pending counts by currency, and only then use bulk linking.
  4. Run the bounded provider-history sync explicitly to discover historical refunds and Square disputes.
  5. If a ledger row was deleted accidentally, relink the still-present source entry. Do not recreate provider events manually.
  6. Use audited manual adjustments only when the provider has no supported event, such as a Polar chargeback.

Invoice history and recovery

The invoice Audit trail tab requires both view_invoices and manage_workspace_audit_logs. It reads audit.record_version through a workspace-scoped, service-only RPC. The old audit_logs view filters out some DELETE events because it checks workspace membership using the new record. Do not use that view as evidence that a deleted invoice has no history. The Finance handlers require 20260916130000_invoice_recovery_and_history.sql. After authorized production sync, the Supabase Production Migration workflow applies pending migrations once its same-commit staging and production deployment prerequisites pass. Verify that workflow actually applied the migration and then check the Finance audit route; a successful skipped job is not schema evidence. Until the RPC exists, deletion fails closed with 503 and never falls back to permanent deletion. Update/history/restore also depend on this migration. Deletions through the Finance invoice endpoint atomically preserve the invoice, its products, promotions, group links, payment transactions, and transaction tags in private.finance_invoice_recovery. Existing inventory and wallet triggers still run. The legacy automatic payment-creation trigger is skipped only inside the privileged restore transaction, so recovery cannot mint a second payment. Restore reuses original IDs and timestamps, restores the payment link, and runs in one transaction. Repeated requests cannot duplicate the payment. Restore requires view_invoices, create_invoices, and delete_invoices. Transactions linked to unrelated records block deletion and require review. An invalid or conflicting reference blocks the entire restore. Historical deletions do not automatically have a recovery snapshot. To investigate an older incident, use a read-only query against audit.record_version, filtering table_name = 'finance_invoices', op = 'DELETE', the workspace in old_record->>'ws_id', and a bounded UTC time window. Inspect deleted line items and payment audit entries separately. Promotions and group links may not have been audited historically. Compare backups and related records before proposing a restore; a header alone is not a complete invoice. Do not recreate payments without proving the original payment is absent, and do not fabricate missing line items or discounts. Run focused database coverage with:
The isolated runner copies Git-tracked files, so register a new migration/test with git add --intent-to-add under the normal commit window before running it. The audit trail opens with currently deleted invoices highlighted for administrator review, including customer, amount, currency, deletion time, and recovery readiness. Older deletions without a verified snapshot are labeled as needing recovery review; opening the audit trail never restores records automatically. The full audit feed includes invoice details, line items, saved discounts, group links, and related payment records. Filter by record type, action, date range, or customer/invoice/actor search, and sort newest or oldest first. Before/after values are available for meaningful changed fields; Finance confidential mode masks amounts in the audit UI too. Promotion tracking begins with this migration; missing historical events and actor identities cannot be reconstructed by the UI. For a disputed tuition month, compare the completed invoice’s customer, linked user group, subscription_months, issue date, and payment record. A receipt for the same student in another class does not settle a debt in the disputed class. Only treat it as a cross-class credit when an explicit adjustment or transfer record identifies the source invoice, destination class, covered months, and actor. Preserve those IDs in the case record. A missing audit event, especially while the audit feed is unavailable, cannot prove that no transfer happened.

Audit history performance

Invoice audit history loads bounded pages of 10, 25, or 50 events. The API fetches one additional event to determine whether a next page exists, without counting the entire audit log. Search, record type, action, date range, and time ordering are applied on the server. Dates and timestamps use the browser timezone shown above the results. Changing page size or filters starts at the first page; sorting and date changes preserve the deleted-invoice review mode. The history RPC reads matching audit events directly instead of materializing every invoice’s complete history. Partial indexes support workspace/time reads, deletions, child events, and legacy payment links. Both audit-log and invoice-view permissions remain required. Deploy the additive performance migration through the normal automatic database workflow; existing RPC callers remain compatible. Each supporting index has its own single-statement concurrent migration so audit writes remain available during index construction. Keep those index files separate from the RPC replacement and do not add transaction wrappers.

Notification refresh diagnostics

The shared notification hook refreshes active notification API queries every 120 seconds while the browser tab is visible and immediately when the tab becomes visible again. Consumers for the same user share one timer. Closed inbox queries remain lazy, and existing mutations invalidate the cache immediately. Offline tabs pause periodic invalidation and refresh immediately on reconnect. This is the default across Next.js satellites and the central web notification surfaces; satellites without the shared subscription use the same two-minute query polling interval. Foreground notifications may therefore take up to two minutes to appear without a mutation or resume event. Colab explicitly selects a 30-second interval through the shared popover, and Mail inbox/snooze timers are unchanged. Regression coverage in use-notifications-subscription.test.tsx verifies 30 refreshes per visible hour, shared consumers, hidden/offline pauses, reconnect, actor teardown and lazy closed inboxes. satellite-notification-polling.test.tsx verifies the two-minute default and explicit Colab interval override. Do not subscribe directly to public.notifications with browser postgres_changes filters. Production notifications are server-owned; the authenticated role has no column SELECT privileges or client RLS policies. Realtime reports P0001: invalid column for filter user_id in that state even though the column exists. Check column privileges and policies before changing the schema; the authenticated notification APIs remain the access boundary.

Category charts and promotions

Category breakdown charts keep the plotting area separate from the legend. By default they show the seven largest categories and combine the remaining amounts under Other categories. Show all categories restores the complete list; legend buttons toggle individual series. Category IDs, rather than names, identify series so repeated names cannot overwrite each other. Finance owns the /[wsId]/promotions management page. The sidebar and page allow members with view_inventory or create_invoices; mutations require the matching inventory create, update, or delete permission. Finance forwards promotion mutations to Inventory using the authenticated session, retaining one owner for validation and commerce synchronization. Configure INVENTORY_APP_URL for a registered Inventory origin. Unregistered destinations and non-loopback HTTP origins are rejected before credentials are forwarded; requests also have a 10-second deadline and inherit caller cancellation. The list is paginated and shared invoice promotion queries are invalidated after mutations.

Audit actor attribution

Finance resolves the authenticated app, CLI, or Supabase session before creating an isolated admin client with a trusted audit actor header. The database accepts that header only for service-role requests. Direct user requests keep their own JWT actor, and the explicit actor in atomic update/delete/restore RPCs takes precedence. This also attributes linked payment, line-item and discount writes made through the Finance route context. Create, update and delete events remain in the audit feed; reads are not logged. An event with an actor ID but no display name shows the recorded user ID. Events written before actor propagation, or by actorless maintenance processes, may have no actor. Do not infer an update/delete actor from the invoice creator or rewrite historical audit records to fill that gap.

Subscription coverage and tuition reporting

Subscription session counting

Across workspaces, the Finance subscription invoice form uses the workspace’s INVOICE_USE_ATTENDANCE_BASED_CALCULATION setting, including invoices opened from a customer or pending-invoice link. Its default is on: completed months and the current month bill present or late attendance, while future prepaid months use planned sessions. When the setting is off, every planned session in the coverage months is billable, regardless of the student’s attendance. Completed paid months remain visible in the preview but cannot be billed again. The planned session count includes scheduled records and the group’s recurring occurrences in the selected coverage period, even when those future occurrences have not been materialized in workspace_user_group_sessions. Canceled occurrences are excluded. The invoice preview reads these dates without reconciling or writing session records. The same session dates feed the total, calendar, notes, and linked tuition product quantity in Finance; Contacts group schedule is the operator view for checking the underlying recurrence. A customer and group supplied in an invoice URL remain selected when the page first loads. When a linked product is absent or has no matching inventory unit, the operator must configure or select a product before creating an invoice; the preview must never invent a tuition price. For a reported Days Attended > 0 with Total Sessions = 0, inspect the authenticated subscription context response’s scheduledSessionsByGroupId, the selected group’s recurring series and canceled exceptions, and the effective workspace billing setting. If the Finance settings panel cannot read that setting, it reports a load error instead of showing a guessed default. Verify a real customer and group in Finance and the same group schedule in Contacts without creating an invoice or changing customer attendance. Use a controlled workspace for any save or toggle test. New subscription invoices persist their paid tuition months in finance_invoices.subscription_months. This nullable array is the canonical coverage record: only its listed months are covered. A payment for March does not settle an unpaid February. The database validates one to twelve distinct month-start dates and derives valid_until from the latest covered month for compatibility with older readers. Invoices whose month array is null retain the legacy valid_until cutoff. Historical start dates cannot be recovered reliably from the cutoff alone, so there is no automatic backfill. Any future historical repair must use verified invoice records. Invoice recovery snapshots preserve the explicit month array. Apply migration 20260923040000_subscription_invoice_coverage_months.sql before releasing the updated Finance API. Local verification uses supabase/tests/subscription-coverage.sql; the gated staging and production migration workflows apply schema changes after their exact-commit prerequisites pass. Generate database types from the migrated local schema. Do not deploy API code that writes the new column before the migration. The invoice analytics Tuition coverage tab groups completed positive payments by their recorded tuition months, independent of when payment was received. Payment value is allocated equally across those months. User, group and user–group pair totals are distinct across the full selected period, not sums of monthly counts. The yearly view covers five years ending in the selected year. Each wallet currency has its own report; no currency conversion occurs. User and wallet filters apply, while coverage year controls replace the invoice list’s payment-date filters. Old invoices with a coverage cutoff in the selected period but no exact months are counted as unallocated and excluded from totals. This is allocated payment reporting, not a ledger revenue-recognition report. Analytics requires view_invoices and reads invoices and wallet currencies in the authorized workspace. It rejects truncated, duplicated or changing-count pages rather than presenting partial totals. Reports are limited to 100,000 invoices. The existing Rust subscription-context port mirrors the exact-month response, workspace scope and pagination rules; it remains a future migration target, not the production runtime. Subscription context also reads every coverage and attendance page, with a 50,000-row limit. A failed context read must be retried before calculating new charges. Unpaid-invoice spreadsheet exports verify page lengths, stable totals and unique student/class identities before producing a file. A failed completeness check shows an error and produces no partial download. This catches incomplete reads; it cannot reconstruct a historical omission without the original export or its date and filters. Pending eligibility still depends on configured group exclusions, class schedules and attendance calculation settings. Pending tuition begins no earlier than the student’s recorded class membership date or the class start date, whichever is later. A class’s earlier scheduled sessions and attendance are never assigned to a student who joined later, including earlier days in the joining month. Membership dates are read in the workspace’s Ho Chi Minh time zone. Contacts group member cards show the current membership’s join date. Adding, removing, and re-adding a member creates separate membership events in the existing group audit feed; the active membership timestamp is stable when a member’s role is changed. Analytics can use that event history for joins and departures without treating a newly added membership as if it existed when the class started. Legacy null timestamps and activity before audit tracking began are unknown, not inferred from a receipt or the person’s name. For a null join timestamp, automated scheduled-session billing starts no earlier than today; staff must reconcile earlier months from source records. Existing completed invoices cover only their recorded tuition months (or the legacy coverage cutoff); a September receipt does not by itself settle July or August if the student was already enrolled in those months. Correct historical membership dates and payments at their source rather than hiding disputed rows by name. The pending tab badge, table pagination and CSV/Excel export all count the same all-month, student-class rows, including older unpaid months. This behavior applies to the shared Finance UI in web and Finance; grouped-by-student mode instead counts one row per student in each surface. The join-date eligibility rule ships in 20260929090000_pending_tuition_respect_student_join_date.sql. The production database migration workflow must finish successfully for the corrected debt rows to appear; a deployed UI and a green application build do not prove the database function changed. Check the migration marker and a read-only customer row after sync. Never manually push the migration from an agent checkout.

Exchange-rate session boundary

Exchange rates are authenticated global reference data. Web Supabase sessions and verified internal app sessions, including Finance, use the same API. A Finance app session does not require a separate Supabase login. Invalid or expired app sessions and anonymous callers are rejected before any database read; existing session suspension and abuse controls remain active. Both verified Supabase JWT users and app-session actors pass the same Rust active-suspension check before currency reads. Lifted and expired suspensions do not block reads; the policy lookup retains the shared live policy’s fail-open behavior on policy-store outages. Supabase JWT currency reads retain the caller token; only verified app sessions use the server read credential. Currency query failures return 500 and never masquerade as missing rates or trigger seeding; initial seeding runs only after a successful empty lookup. Successful rates are private and not stored in a shared HTTP cache. The client-side exchange-rate hook uses the internal API client. The existing wallet-details server component continues to read rates directly through its server-only admin client. Regression coverage: apps/web/src/app/api/v1/exchange-rates/route.test.ts exercises the real session middleware with injected authentication dependencies; packages/internal-api/src/finance-exchange-rates.test.ts covers transport and error propagation. Rust source parity is covered by apps/backend/src/tests/exchange_rates_session.rs; Rust does not serve production.

Profile Timeline creation boundary

Native Profile Timeline requests pin until when loading the first activity page. The Finance activity RPC applies the optional p_created_at_until constraint to transaction creation time before limit, offset, or total-count calculation. created_at ordering uses transaction ID as the unique tie-breaker. Newer transactions therefore cannot shift the next page of the pinned traversal. This is an upper creation boundary, not a historical snapshot: edits, deletions, and explicitly backdated creation timestamps can still change visible records. This rule applies to the Web-owned mobile activity API and shared Finance RPC. Existing Finance list, export, attachment, and Rust migration callers omit the new parameter and retain their unbounded default; p_start_date and p_end_date continue filtering the transaction’s business date (taken_at). Actor checks, wallet viewing windows, granular permissions, and confidential field redaction remain unchanged. Calendar permission verification runs alongside other activity sources, but Calendar metadata is read only after permission succeeds; denied access is omitted, and permission failures remain partial errors. Regression evidence lives in the mobile activity route tests and apps/database/supabase/tests/mobile-activity-transaction-boundary.sql. The latter covers unique ordering, insertion between pages, the optional default, and actor visibility. Schema application, pgTAP execution, and generated Supabase types require the exact-commit CI migration/typegen gate; source inspection and route unit tests do not prove that a deployed RPC accepts the new parameter.

Subscription invoice schedule and inventory admission

Subscription invoice previews in the shared web and Finance UI use the subscription context’s schedule as authority only for the selected civil month and prepaid range. A returned empty group schedule means no scheduled charges in that range; removed or cancelled dates from the older group-session list do not become extra charges. Dates outside the fetched range remain unchanged. Missing, loading, failed, or malformed context is not an authoritative empty schedule and blocks creation until billing data is available. Completed invoice coverage, attendance rules, recurrence cancellation and stored history retain their existing behavior. A selected class link with a missing product, missing billing unit or unit that is absent from the current product inventory blocks the entire invoice with a review instruction. The UI does not silently drop that charge or select another unit. Existing valid warehouse fallback and finite-stock quantity rules remain. The create action and its handler both enforce these admissions. Regression coverage lives in subscription-billing-groups.test.ts, subscription-admission.test.ts, use-subscription-auto-selection.test.tsx and the actual subscription-invoice.test.tsx form. These source checks do not prove a customer’s create/open/export incident or production storage behavior. Invoice browser preferences are optional: a blocked storage getter or read uses the existing default values and completes initialization, so opening an invoice and reaching print/image export does not depend on persistent storage. Valid saved preferences and in-memory updates remain supported. Regression coverage uses the real shared hook and InvoiceCard with synthetic storage denial; this reproducible restriction failure is not proof of the original customer crash, browser export completion, or deployed behavior. Invoice schedule previews use the workspace billing timezone for both projected recurring classes and materialized sessions. A class keeps the same invoice civil day and month when materialized, including midnight and daylight-saving boundaries; multiple classes on one billing day remain one date. Recurrence identity and cancellation use the original series timezone, and previewing never writes schedules. planned-session-dates.test.ts covers projected/materialized parity and cancellation controls. This source regression does not establish the cause of a customer screenshot or deployed invoice behavior.

Invoice create completion belongs to the admitted draft

Subscription invoice creation captures the current account lifetime, workspace, customer, draft revision, payload, and create/print/download options. Leaving and returning to an account, workspace, or customer permanently expires an earlier completion. Editing the same customer’s draft also prevents the previous response from resetting that newer draft, navigating, announcing a result, or invalidating its queries. An expired response does not undo an already admitted server write. A new scope can create while an old request is pending; the old request cannot clear the new pending indicator. Current-draft success and errors retain their existing behavior, and multiple-create mode resets only its own unchanged draft. The mounted subscription-create-scope.test.tsx regressions exercise real account lifetimes, held responses, newer drafts, resets, and duplicate submission. This source regression does not establish the cause of the original customer complaint or verify authenticated invoice creation in production.

Complete schedule reads for subscription preview

Subscription previews retain the workspace/group and requested instant-period filters and never reconcile or write schedules. Materialized dates and recurring occurrence suppression use complete reads rather than treating a capped first page as authority: cancelled tail occurrences must not reappear as projected classes. These reads use UUID keyset order, 500-row pages, exact counts and a maximum of 10,000 rows/20 pages per query. Missing or changing counts, early short pages, invalid/repeated/non-advancing IDs, database errors or an exceeded budget fail the preview with a fixed error. The existing invoice-context failure and checkout blocking paths handle that failure; they never receive a partial successful date map. A deployed row cap below 500 also fails closed. Larger datasets need a separately reviewed bounded query strategy rather than unlimited pagination. This is statement-level validation, not a transaction snapshot. Concurrent writes can make a read fail; count checks do not prove every same-count mutation was observed. The composed regressions in planned-session-paging.test.ts model PostgREST filters, ordering, inclusive ranges and caps, including cancelled tail instances, tied starts, tenant/date scope, late errors and budget failures. They are source evidence, not the original customer’s deployed incident or a billing write. Invoice quantities, stock, historical coverage and schedule policy are unchanged.

Subscription context cache follows the verified account lifetime

The shared subscription invoice context query uses its workspace, customer, selected groups and period together with the verified account and permanent account lifetime. Changing accounts or leaving and returning to the same account requires a new authorized context read. A pending response from an expired account lifetime cannot publish successful data for the new account lifetime. Without the verified account provider, the query does not admit requests. Fresh context is still reusable within the same account lifetime, and workspace invoice invalidation still refreshes it. Existing five-minute freshness and ten-minute garbage collection bounds remain. This rule covers this context query; other Finance caches are outside this change. The real hook, actor provider, API adapter/client and synthetic transport regressions in subscription-context-actor.test.tsx cover account switches, held success/error responses, account return, cache reuse, missing actor admission and invalidation. These tests do not prove the cause of the original customer incident or deployed customer invoice behavior.

Invoice customer group reads follow the verified account lifetime

The shared useUserGroups query follows the same permanent account lifetime as subscription invoice context, using a shared lifetime-key helper. Group data for one account cannot become a fresh cache hit or an admitted pending result for a replacement account, including departure and return to the original account. Missing-account automatic and manual reads do not dispatch requests. Current account errors remain visible to the query consumer and permit explicit retry. Existing group mapping, workspace/customer key positions, five-minute freshness, ten-minute garbage collection, request timeout and retry policy remain unchanged. Workspace-prefixed group invalidation still works; invoice mutation invalidation continues to exclude groups. Other Finance caches are outside this change. user-groups-actor.test.tsx exercises real actor lifetimes, the actual hook and API adapter/client, held synthetic responses, current error recovery and both queries sharing a lifetime token. These source regressions do not verify the original customer incident or authenticated deployed invoice behavior.

Invoice customer search retains placeholders only within its scope

Customer-list and selected-customer reads in the shared invoice form follow the verified account and permanent account lifetime. Replacement accounts, including departure and return to the same account, require new reads; expired list, detail or page callbacks cannot dispatch or publish for a new account lifetime. Without the verified account provider, automatic, manual and paged reads fail closed. Previous list rows remain useful while changing search text within the same account lifetime and workspace. They are not placeholders in another workspace or account, and cannot suppress the new scope’s selected-customer lookup. Current errors and explicit recovery retain their existing behavior. Normalized search, 25-row inclusive paging, count-based continuation, selected-detail fallback, 30-second freshness and five-minute garbage collection remain unchanged. The real provider, query, API adapter/client and synthetic transport cases in customer-search-scope.test.tsx cover replacements, account return, held success and errors, workspace placeholders, old page callbacks, missing actor admission and recovery. Existing customer-search tests retain their behavior assertions. This is source regression evidence, not verification of the original customer incident or a production invoice mutation.

Invoice history follows the verified account lifetime

The active paginated invoice-history query follows the verified account and permanent account lifetime as well as workspace and customer. Replacement accounts, including departure and return to the same account, require a new authorized read. Expired responses and retained page callbacks cannot publish history or dispatch requests after their account lifetime ends. Missing-account automatic, manual and paged reads fail closed. The default ten-row page size, count-based continuation, inherited query-provider freshness, garbage collection and retry policy remain unchanged. Workspace invoice mutation invalidation still refreshes active history. Current transport errors remain available on the query and explicit retry remains usable; this change does not add an error presentation to the history accordion or alter other queries. invoice-history-actor.test.tsx uses the real mounted accordion, actor provider, hook, API adapter/client and synthetic transport to cover replacement, account return, held success/error responses, missing account, old page callbacks, cache reuse, pagination, invalidation and current-error recovery. This is source regression evidence, not proof of the original customer incident or deployed invoice behavior.

Invoice history failures require explicit recovery

The shared invoice-history accordion distinguishes failed reads from successful empty history. English and Vietnamese surfaces show a fixed accessible failure notice and Retry action without exposing transport diagnostics. Initial failure recovery rereads history; later-page recovery retries the failed page while retaining loaded rows. Pending recovery disables duplicate actions. Automatic scroll loading and queued disconnected observer callbacks do not retry failures. The query’s account-lifetime admission, provider retry policy, pagination and workspace invalidation remain unchanged. This behavior covers the mounted history accordion, not other Finance queries or server authorization. Real mounted regressions in invoice-history-error.test.tsx cover initial 403/503 failures, genuine empty success, pending recovery, retained rows, page continuation and queued observer callbacks in both languages. These source tests do not establish the original customer incident or authenticated production behavior.

Invoice history keeps rows with an unrenderable timestamp

The shared invoice-history accordion parses each nonempty creation timestamp once and checks for a finite JavaScript date before formatting it. A timestamp that cannot be displayed uses the existing localized No date label instead of causing a render exception. Valid dates retain their existing locale formatting; missing dates, customer links, query admission, filters and invoice accounting are unchanged. This display fallback does not repair or rewrite stored dates. The real mounted EN/VI cases in invoice-history-date.test.tsx reproduce render exceptions for synthetic malformed and infinite timestamps and cover valid, null, missing and empty controls. These are rendering-boundary regressions, not proof of a malformed production row or the cause of the original customer complaint.

Invoice PNG export lifetime

PNG export belongs to the admitted signed-in actor lifetime, verified invoice workspace, and current printable card. Replacing the actor, workspace, invoice, or any rendered invoice input invalidates a pending capture permanently, including leaving and returning to the same actor. Render inputs include confidential amount visibility, language, currency, invoice content, products, promotions, configuration, and compact/dark preview choices. Unchanged semantic rerenders remain exportable. Stale renderer/import/blob completions cannot download, notify, clear a newer export’s pending state, or consume a newer automatic-image URL intent. Missing actor or workspace admission fails closed. The existing print path is unchanged. invoice-png-lifetime.test.tsx exercises actual card/templates/provider lifetime with synthetic held renderer boundaries; this is bounded source evidence, not proof of the original customer incident or a live customer export.

Invoice template dates

Full and compact invoice cards omit a date that cannot be rendered as a finite JavaScript date, following the existing missing-date omission. Valid dates retain the same English/Vietnamese locale formatting and preview, print, and PNG surfaces. This is a rendering boundary only: invoice timestamps, accounting, recurrence, and API date authority are unchanged. The original customer’s crash cause and presence of malformed customer timestamps remain unproven. invoice-card-date.test.tsx covers actual card/template transitions in both locales and preview modes, malformed date boundaries, missing dates, and valid formatting. Source checks do not establish a live customer export or deployment.