AccountingKitDocs

Pilot QA runbook

Manual acceptance QA executed against a deployed AccountingKit before the first customer pilot.

Automated suites prove internal behavior against mocks and a local database. This runbook proves the deployed system against a real Xero organization, a real customer webhook receiver, and a real browser. Execute it in order; a phase depends on the artifacts produced by the phase before it.

Record every result in the defect log. A pilot starts only when every P0 check passes.

Legend

SymbolMeaning
P0Blocks the pilot. No workaround is acceptable.
P1Blocks the pilot for the affected flow only. Needs a documented workaround.
P2Ship and fix during the observation window.

Phase 0 — Fixtures

Prepare these before touching the API. Every later phase references them by name.

IDFixtureRequirement
F-01ORG_AConsole organization that owns the pilot. Two console users: one owner, one non-owner member.
F-02ORG_BA second, unrelated console organization. Exists only to prove cross-organization isolation.
F-03XERO_DEMOA Xero Demo Company or disposable staging organization. Never the customer's production ledger.
F-04XERO_SECONDA second Xero organization authorized by the same Xero login. Proves tenant selection.
F-05KEY_TEST_Aacct_test_ key in ORG_A.
F-06KEY_LIVE_Aacct_live_ key in ORG_A.
F-07KEY_TEST_Bacct_test_ key in ORG_B.
F-08KEY_REVOKEDA key in ORG_A that is revoked immediately after creation.
F-09HOOK_GOODAn HTTPS receiver that logs raw body plus headers, verifies the signature, and returns 200.
F-10HOOK_BADAn HTTPS receiver that always returns 500. Used to force delivery failure and replay.
F-11Bank accountA Xero bank or clearing account in XERO_DEMO, with its Xero AccountID recorded.
F-12XERO_APP_ACustomer-owned Xero app for ORG_A; client credentials and webhook key are available.
F-13XERO_APP_BA second app or fixture owned by ORG_B for provider-app ownership checks.

HOOK_GOOD must log the exact bytes it received. Signature verification that re-serializes JSON before hashing will pass locally and fail in production; the receiver is part of what is under test.

Phase 1 — Pre-deployment gate

IDPriCheckExpected
G-01P0From a clean worktree: DATABASE_URL='postgresql://app:app@localhost:5433/app?sslmode=disable' pnpm verify:local-mvpExits 0.
G-02P0pnpm --filter @accountingkit/server db:migrate, db:rollback to the base, db:migrate again on a scratch databaseAll three succeed; final schema equals the first.
G-03P0Confirm the deployed image digest matches the commit under testRELEASE_ID and IMAGE_DIGEST in the deployment match HEAD.
G-04P0Review the deployed variable set against deploy/cloudrun/env.yaml and the SERVICE_SECRETS manifest in deploy/cloudrun/config.envNo placeholder value survives; ACCOUNTING_ENCRYPTION_KEY is unique to this environment and is not the local one.
G-05P0ACCOUNTING_QUEUE_BACKEND=postgres, ACCOUNTING_EMBEDDED_WORKER_ENABLED=true, Cloud Run min/max instances match the declared release valuesConfirmed in the deployment dashboard; provider budgets are sized for the configured maximum.
G-06P1CONNECT_ALLOWED_REDIRECT_ORIGINS lists only origins the pilot customer actually usesNo wildcard, no localhost in a live deployment.

G-01 is the only step in this runbook that a green CI run substitutes for. Everything after it must be executed against the deployed service.

Phase 2 — Deployment smoke

IDPriCheckExpected
S-01P0API_ORIGIN=... WEB_ORIGIN=... bash scripts/verify-public-deployment.shExits 0.
S-02P0API_ORIGIN=... API_KEY=$KEY_TEST_A PROVIDER_APP_ID=$XERO_APP_A_ID bash scripts/qa-pilot-smoke.shExits 0; every assertion prints ok.
S-03P0GET /health and GET /ready over plain HTTPRedirected or refused; never served over http:.
S-04P1Stop PostgreSQL, then GET /ready503 not_ready. GET /health may stay 200.
S-05P1Restore PostgreSQL, wait one poll interval, GET /readyReturns to 200 ready without a restart.

Phase 3 — Authentication and tenancy

This phase is the one that must not be shortened. Every check here is a data-leak boundary.

IDPriCheckExpected
A-01P0GET /v1/connections with no Authorization401 UNAUTHORIZED. No data.
A-02P0Same, with Authorization: Bearer not-a-key401. Response body does not reveal whether the key existed.
A-03P0Same, with KEY_REVOKED401.
A-04P0With KEY_TEST_B, read a connection_id that belongs to ORG_A404, not 403. A cross-organization ID must not be confirmed.
A-05P0With KEY_LIVE_A, read a connection created under KEY_TEST_A404. Environments are separate data planes inside one org.
A-06P0With KEY_TEST_B, POST /v1/connections/<ORG_A connection>/sync404. Nothing is queued; confirm no operation row appears.
A-07P0With KEY_TEST_B, GET /v1/operations/<ORG_A operation_id>404.
A-08P0With KEY_TEST_B, POST /v1/operations/<ORG_A operation_id>/replay404. No new attempt on the original operation.
A-09P0With KEY_TEST_B, POST /v1/webhook-deliveries/<ORG_A delivery_id>/replay404.
A-10P0Create a key in the console; read the list againThe secret is shown exactly once. The list shows prefix and last four only.
A-11P0Revoke a key in the console, then use it within one minute401 immediately. No cache window.
A-12P1Sign in as the ORG_A non-owner memberWhatever the role permits is consistent between the console UI and the API; a hidden button is not an authorization control. Attempt the key-creation call directly.
A-13P1Console session cookie replayed against /v1/*401. Browser sessions must not authenticate the machine API.
A-14P1KEY_TEST_A sent to a console endpoint under /accounting/*401.
A-15P1Cross-origin POST to a console endpoint from an unlisted originBlocked by CSRF/CORS.

Phase 4 — Xero app and connect flow

IDPriCheckExpected
C-00P0Register XERO_APP_A in the console and copy its generated URLs to XeroCredentials are never returned; the callback and opaque app-specific webhook URL are copyable.
C-01P0POST /v1/connect-sessions with provider, external_account_id, redirect_uri, and provider_app_id201 with connect_url and expires_at; an app ID owned by ORG_B is rejected without revealing it.
C-02P0redirect_uri outside CONNECT_ALLOWED_REDIRECT_ORIGINSRejected at session creation, not after the customer authorizes Xero.
C-03P0Open connect_url in a clean browser profile; authorize XERO_DEMOReturns to the redirect URI; connection becomes active.
C-04P0Authorize a Xero login with access to both XERO_DEMO and XERO_SECONDTenant selection is required; the connection stays non-active until one organization is chosen.
C-05P0GET /v1/connectionsprovider_tenant_id, provider_tenant_name, external_account_id, and scopes including offline_access are correct.
C-06P0Open the same connect_url a second time after completionCannot be reused to attach another tenant.
C-07P0Open a connect_url after expires_atRefused with a clear message, not a stack trace.
C-08P0Connect XERO_DEMO twice through XERO_APP_A, then once through another appThe duplicate within one app is refused. The same tenant through another app is allowed and receives only that app's webhook events.
C-09P0Cancel at Xero's consent screenConnection does not become active; the customer sees a recoverable state.
C-10P1Abandon mid-flow, then restart the same sessionNo orphaned pending connection blocks a retry.
C-11P1Complete the flow on a phone-width viewportThe hosted pages are usable; the pilot customer's end users may not be on a desktop.
C-12P0DELETE /v1/connections/:id, then inspect Xero Connected Apps and read a resourceThe exact Xero app connection is removed before local credentials are purged; reads fail cleanly and reconnecting works afterwards.

Phase 5 — Reads

Run each read against XERO_DEMO and diff it against the Xero UI by eye. Cached and live reads are different code paths; check both.

IDPriCheckExpected
R-01P0GET /v1/connections/:id/{contacts,invoices,quotes,payments,purchase_orders,accounts,tax_rates,branding_themes,organizations}Each returns 200 with meta pagination. source is cache.
R-02P0Page through a list with page and limit past the final pageNo duplicated or skipped records; the empty page is not an error.
R-03P0Live read by provider ID for an invoice, quote, contact, and purchase ordersource is provider; totals, tax, status, and line items match Xero exactly.
R-04P0Compare a multi-line invoice with a discount, a non-default account code, and a tax rateDiscountAmount, Xero AccountID, account code, and tax type all survive normalization.
R-05P0Filter by number, status, and external_referenceExact matches only. A partial number must not return a different document.
R-06P0Read an invoice in a currency other than the organization's base currencyCurrency and currency rate are correct; no silent conversion.
R-07P1Read a contact with two addresses, a phone, a tax number, and a contact personNothing is dropped.
R-08P1Read a resource ID that does not exist in Xero404 with the standard envelope.
R-09P1Read on a connection whose Xero authorization was revoked at XeroA clear action_required style failure, not a 500.
R-10P2Time a cold cached list and a live readRecord the numbers. They become the pilot's latency baseline.

Phase 6 — Writes and idempotency

IDPriCheckExpected
W-01P0Any write with no Idempotency-Key400 BAD_REQUEST. Nothing reaches Xero.
W-02P0Create a contact, then repeat the identical request with the same keySame result, Idempotent-Replayed: true, exactly one contact in Xero.
W-03P0Reuse the same key with a different bodyRejected. The original result is not overwritten.
W-04P0Fire the same create twice concurrently with one keyExactly one Xero record. One response may be a replay.
W-05P0Create a draft invoice with external_reference set to an order IDXero Reference carries the order ID; Xero's own sequential invoice number is used.
W-06P0PATCH an invoice's status onlyDocument number, reference, branding theme, and every line survive unchanged.
W-07P0PATCH a contact with a subset of fieldsAccount number, status, phones, addresses, tax number, and default currency are not erased.
W-08P0Draft a quote with Terms, then update only its statusQuoteNumber and Terms are preserved.
W-09P0Convert an accepted quote to an invoiceInvoice date, due date, AUTHORISED status, reference, branding theme, tax mode, and every line clone exactly.
W-10P0Record a payment against AUTHORISED invoice using the F-11 bank accountXero shows the payment on the routed account; the operation reaches succeeded.
W-11P0Cancel a DRAFT invoicecancellation_status: deleted.
W-12P0Cancel a SUBMITTED and an AUTHORISED invoiceCorrect delete-versus-void behavior for each; verify in Xero.
W-13P0Cancel a PAID invoiceRefused with a clear error. Never silently voids a paid document.
W-14P0Cancel by stored Xero provider ID via /invoices/by-provider-id/:provider_id/cancelSame outcome as cancelling by internal ID.
W-15P0Create a purchase order with delivery date, delivery address, attention-to, telephoneAll provider-native fields land in Xero.
W-16P1Write with an invalid contact_id, a negative amount, an empty line_items, a bad date400 with a message naming the field. Nothing partially written.
W-17P1Write a line description at the 4000-character limit and one past itAccepted and rejected respectively, at the boundary the contract states.
W-18P1Write to a read-only resource type (tax_rates, branding_themes, organizations)Refused.
W-19P1Set READ_ONLY_MODE=true, retry one read and one writeRead succeeds; write returns 403 READ_ONLY. Restore the flag afterwards.

Phase 7 — Documents

IDPriCheckExpected
D-01P0invoice.download_pdfapplication/pdf; opens; content matches the current Xero invoice.
D-02P0quote.download_pdfSame.
D-03P0purchase_order.download_pdfSame.
D-04P0Edit the invoice in Xero, download the PDF againThe new PDF reflects the edit. A stale cached render here is a pilot blocker.
D-05P0invoice.get_online_urlThe URL opens Xero's hosted invoice view for the right document.
D-06P1organization.get_deep_linkLands on the correct Xero organization.
D-07P1Provider action with neither resource_id nor provider_id400, not a 500.
D-08P1Provider action against a connection in another organization404.

Phase 8 — Payment lifecycle and webhooks

This is the flow the pilot customer depends on most and the one mocks prove least. Point the connection's webhook endpoint at HOOK_GOOD first.

IDPriCheckExpected
H-01P0POST /v1/webhook-endpoints for HOOK_GOODSigning secret returned once and never again.
H-02P0Xero intent-to-receive against /webhooks/xero/<XERO_APP_A id>Passes, and Check setup reports the webhook as verified.
H-03P0Record a payment in the Xero UI against an AUTHORISED invoiceHOOK_GOOD receives exactly one invoice.updated, one invoice.payment_changed, and one invoice.paid.
H-04P0Verify each delivery's AccountingKit-Signature against the exact raw bodyValid. t is within five minutes.
H-05P0Inspect the invoice.paid payloadAmount paid, amount due, status, both invoice IDs, provider revision, and document.refresh_required are present and correct.
H-06P0Re-send the identical Xero notification (replay from the Xero portal or by hand)No duplicate customer event and no second accounting write.
H-07P0Post to /webhooks/xero/<XERO_APP_A id> with a wrong X-Xero-SignatureRejected before parsing or persistence.
H-08P0Post a batch containing several events in one Xero deliveryEvery event is processed; the response is fast enough for Xero's five-second deadline.
H-09P0Take the API down briefly, change an invoice in Xero, bring it backThe 15-minute reconciliation, or an explicit POST /connections/:id/sync {"scope":"invoices"}, recovers the missed transition with the same event contract.
H-10P0Force a sequence gap (skip a Xero event number)The hole is recorded and reconciliation is queued only for active connections using XERO_APP_A.
H-11P0Partially pay an invoice, then pay the remainderinvoice.payment_changed on the partial payment; invoice.paid only on the transition to paid, and only once.
H-12P0After invoice.paid, download the PDF againShows the paid state. The pre-payment PDF is not reused.
H-13P1Void a payment in XeroThe resulting transition is delivered and is not silently swallowed.
H-14P1Register a second endpoint for a subset of event typesOnly subscribed types arrive at it.
H-15P1DELETE a webhook endpointDeliveries stop; existing delivery history is retained.
H-16P0Repoint to HOOK_BAD, trigger an eventRetries are visible with attempts, response status, and next attempt; the delivery ends in a terminal state rather than retrying forever.
H-17P0GET /v1/webhook-deliveries?state=dead_letterThe failed delivery is listed.
H-18P0Repoint to HOOK_GOOD, then POST /v1/webhook-deliveries/:id/replay with a reasonDelivered. Original attempt history is intact.
H-19P0Replay with no reason or an empty one400.
H-20P1HOOK_GOOD responds slowly (about 10 seconds)Timeout and retry behave as documented; no duplicate accounting side effect.

Phase 9 — Durability and recovery

IDPriCheckExpected
O-01P0Queue an automation; poll GET /v1/operations/:id202 on create, then a terminal state. input, attempts, and timestamps are populated.
O-02P0Restart the API while an operation is runningThe lease expires and the operation resumes from its persisted steps; completed steps do not re-run against Xero.
O-03P0Force a Xero failure (invalid account code) and let retries exhaustState reaches dead_letter with a readable terminal error. No partial document left in Xero.
O-04P0Fix the cause, then POST /v1/operations/:id/replay with a reasonSucceeds. Prior successful steps are not repeated. The audit log records reason, actor, previous state, and previous attempt count.
O-05P0Expire the Xero access token, then issue several concurrent requestsXero's rotating refresh token is consumed exactly once; every request succeeds with the same new credentials.
O-06P0Revoke the Xero authorization at Xero, then call the APIThe connection reports action_required; reconnecting restores service without duplicating the connection.
O-07P1Run each of the seven automation recipes once against XERO_DEMOEach reaches succeeded and produces the expected Xero record. automation.succeeded is delivered.
O-08P1Force a recipe failureautomation.failed is delivered with a usable error.
O-09P1Queue many operations on one connection at onceThe per-connection provider budget throttles rather than triggering Xero rate limits; watch for 429 in logs.
O-10P1Replay an operation that already succeededRefused or a no-op. It must not duplicate an accounting write.

Phase 10 — Console

IDPriCheckExpected
U-01P0Sign in with Google, Microsoft, and an email magic link, then sign outEvery transactional email arrives and its links work against the deployed origin.
U-02P0Switch between the test and live environment in the consoleConnections, keys, operations, and usage all change together. No live data under test.
U-03P0Create and revoke an API keyThe secret is shown once with a copy affordance and an explicit warning.
U-04P0Register a Xero app, copy both setup URLs, and run Check setupNo credential is displayed again; OAuth, webhook, and scope status are accurate.
U-05P0Start a connect session, select the intended Xero app, and open the flowThe OAuth popup closes after success, accounting queries refresh, and the new connection workspace opens without a manual refresh.
U-06P0Trigger a sync and disconnect from the connections viewBoth reflect true server state.
U-07P0Open the automations view with a failed and a dead-letter operation presentBoth are visible and distinguishable. An operator can find a failure without database access.
U-08P1Invite a teammate and accept the invitationWorks end to end on the deployed origins.
U-09P1Load every view with zero dataUseful empty states, no spinners that never resolve.
U-10P1Search and filter in the connections and automations viewsCorrect results; no crash on punctuation or an empty query.
U-11P2Reload each view directly by URL, and use browser backDeep links resolve; the persisted environment remains selected.
U-12P2Console at 1280px and at a phone widthNo overflow or clipped controls.
U-13P0Start a session for an existing customer, authorize two Xero organizations, and choose the intended oneThe customer ID is preserved, the organization choice is explicit, and no duplicate connection is created.
U-14P0Cancel the Xero consent popup, then open the same link againThe console reports cancellation, keeps the unconsumed link, and the second attempt can complete.
U-15P1Open a connection workspace in each statusHealth gates, safe identifiers, relevant recovery action, workflow coverage, and recent operations reflect server state.

Phase 11 — Commercial

IDPriCheckExpected
M-01P0Set the pilot contract with commercial:set-contractThe console usage view shows the correct included volume and currency.
M-02P0Perform a known count of billable calls, then re-read usageCounts match. Idempotent replays are not double-counted.
M-03P0Generate a period statement with commercial:statementMeasured counts, allowances, overage, and total are correct and frozen.
M-04P1Re-run the statement for the same periodIdentical output. The frozen result does not drift.
M-05P1Verify connection-limit enforcement at the contract's limitRefused with a clear error, not a partial connection.

Phase 12 — Operational readiness

IDPriCheckExpected
P-01P0Grep a full day of deployed logs for the Xero client secret, an API key, a refresh token, an authorization code, and a raw webhook bodyZero hits. This check is repeated after the first pilot week.
P-02P0Inspect the credentials table directlyXero tokens are ciphertext at rest.
P-03P0Confirm a restorable database backup exists and restore it once into a scratch databaseRestore succeeds. An untested backup is not a backup.
P-04P0Confirm alerting on queued-operation age, dead-letter count, Xero 401/429/5xx, OAuth refresh failure, and webhook delivery latencyEach alert has a destination a human actually reads.
P-05P0Correlate one request end to end using X-Request-IDThe header round-trips and appears in logs, the operation, and the audit record.
P-06P1Redeploy while traffic is flowingNo dropped operation; no duplicate provider write.
P-07P1Confirm the documented rollback for the deployed releaseWritten down, and the migration in this release is safe to roll back under it.
P-08P1Agree the pilot support path with the customerWho they contact, expected response time, and how a dead letter is escalated.
P-09P2Re-run pnpm --filter @accountingkit/docs build and read the quickstart as the customerA developer can reach a first successful call using only the published docs.

Exit criteria

  1. Every P0 check passes on the deployed service against a real Xero organization.
  2. Every P1 check either passes or has a written workaround the pilot customer has accepted.
  3. Phases 8 and 9 have been executed at least twice, on separate days, with a fresh connection.
  4. docs/internal/pilot-cutover.md live acceptance gates 1 through 6 are satisfied by evidence from this run, referenced by check ID.
  5. The defect log has no open P0 or P1 entry.

Defect log

IDCheckPriObservedExpectedRequest IDStatus

Capture the request ID for every failure. A defect without one costs more to reproduce than it cost to find.

On this page