Power features
Accounting sync — best practice
Everything you need to know about how CoPortal stays in step with QuickBooks, Xero, and Sage — what flows in which direction, the recommended source of truth, and the dedup rules that prevent the most common nasty surprises (split clients, double reminders, lost partial payments).
The mental model: CoPortal is the source of truth
Once you connect an accounting integration, you can run things one of two ways. CoPortal is built around the first, recommended way:
- •CoPortal-first (recommended): your day-to-day work happens in CoPortal. You create clients, send quotes, raise invoices, record payments. CoPortal pushes those to your accounting software so the books are kept up to date. The accounting software is the legal ledger; CoPortal is where the work happens.
- •Accounting-first: you keep using QB / Xero / Sage as primary, and CoPortal imports from them so your client portal still has invoices to show. Workable, but you give up most of CoPortal's automation (reminders, follow-ups, partial-payment tracking) because those events fire on changes that originate in CoPortal.
Almost everything below assumes CoPortal-first. The accounting-first sections are clearly marked.
What flows which way
Compact map of every sync direction, by entity:
How clients are matched between CoPortal and accounting
Every CoPortal client carries an external ID for each connected integration: qb_customer_id, xero_contact_id, sage_contact_id, bc_customer_id. These are the canonical link — names and emails are secondary.
Outbound match rules
When CoPortal pushes an invoice or quote to your accounting software:
- If the CoPortal client already has the relevant external ID, that's used directly — no lookup.
- Otherwise CoPortal searches the accounting software by the client's email. Exact email match (case-insensitive) → reuse that customer.
- No email match → CoPortal creates a new customer in the accounting software using the client's name + email + country.
- Whichever path was taken, the resulting external ID is saved back on the CoPortal client so step 1 covers all future syncs.
Inbound match rules (QB customer import preview)
When you preview-import QuickBooks customers, each row is tagged:
- •New — no email match in CoPortal at all → creates a fresh client.
- •Will link — email matches a CoPortal client that has no QB linkage yet → attaches the QB ID to that existing client. Preserves projects, invoices, contacts, history. Preselected by default because it's the safe choice.
- •Linked — same QB customer ID is already on a CoPortal client → no-op, skipped.
- •Duplicate email — email matches a CoPortal client that's already linked to a different QB customer. This is the genuine multi-entity case (e.g. two companies sharing a billing inbox). Tickable but not preselected — you have to consciously create the second row.
Invoice import (QuickBooks): everything you should know
The invoice importer sits in Settings → Integrations → Preview invoices (or on the Invoices page → Import QB). It's always a dry run first. Per row:
Status pills you'll see in the preview
- •Will import — fresh invoice, customer is already linked to a CoPortal client. Preselected.
- •Needs auto-create — customer isn't linked to a CoPortal client yet. Tick the Auto-create missing clients toggle in the footer to import these. The label switches to Will create client + import. Otherwise these rows skip — go to Settings → Integrations → Preview customers first to bring those clients in (recommended for cleaner data).
- •Already imported — same QB invoice ID is already on a CoPortal invoice. Skipped.
- •Number conflict — the invoice number this would import as already exists in CoPortal. Imports anyway under
QB-<number>-2(or -3, etc.) so nothing collides. - •No customer data — the QB customer record is missing both name and email. Skipped — fix the customer in QB first.
Two toggles in the footer
- •Auto-create missing clients (off by default) — when on, rows tagged Needs auto-create become importable. The CoPortal client is created inline using the QB customer's DisplayName + PrimaryEmailAddr. Off by default because the cleaner path is to run customer import first so client creation happens centrally with the dedup safeguards.
- •Auto-send unpaid invoices to clients (off by default) — when on, every imported invoice that comes across as “unpaid + not draft” gets emailed to the client via CoPortal at import time. Off by default because the customer almost certainly already received the QB email when the invoice was issued — sending a second from CoPortal would be confusing.
What the import preserves
- •Original issue date — CoPortal's
created_atis set to the QBTxnDate, so analytics, revenue trends, and date-window filters bucket the imported invoices on their original month. - •QB DocNumber — preserved as part of the CoPortal invoice number, always prefixed with
QB-so the source is obvious at a glance (e.g.QB-1024). - •Partial payments — for any invoice with
TotalAmt − Balance > 0, the already-paid amount is recorded as a payment-history row on the CoPortal invoice. So a R10 000 invoice with R5 000 already paid lands as a CoPortal invoice with a R5 000 payment row attached and an R5 000 outstanding balance — not a phantom R10 000 outstanding. - •Reminder gating — imported invoices come in with
reminders_enabled = falseso the CoPortal auto-reminder cron doesn't fire on them by default (avoiding double-reminders alongside QB's own dunning). You can flip this on per-invoice from the editor if you want CoPortal to take over reminder duty for a specific invoice.
Outbound invoice sync: when does it fire and what gets pushed?
Two ways an invoice ends up in your accounting software:
- •Manual sync — every invoice editor has a Sync to QuickBooks / Xero / Sage button. Pushes immediately. Use this when auto-sync is off or you want to control timing.
- •Auto-sync (per integration toggle on the Integrations page) — every invoice you Send or change auto-syncs in the background. No button needed.
Independently, each integration has a Sync as draft toggle — if on, the invoice is created in the accounting software as a draft for you to review before it goes Awaiting Payment. Off means it lands as Awaiting Payment immediately.
Each integration also has a Display-only payments toggle. When on, marking an invoice paid in CoPortal will never push paid status to that accounting system. QuickBooks and Sage already behave this way today; the toggle locks the contract so future code paths can't regress. Xero is the one where the toggle changes live behaviour — without it, a CoPortal-paid invoice is pushed as Status=PAID; with it on, it's clamped to AUTHORISED.
What gets pushed
- •Invoice number (preserved as the reference / DocNumber)
- •Line items: description, quantity, unit price
- •VAT / GST: applied at the rate set on the CoPortal invoice; matched to the closest external tax-code
- •Due date
- •Currency (Xero, QB). Sage uses workspace default.
- •Notes (as memo / private note field)
- •External ID + sync timestamp written back onto the CoPortal invoice — second sync updates the same row, never duplicates
What does NOT get pushed
- •Payment events. Marking an invoice paid in CoPortal does not record a payment in QuickBooks or Sage. Record the payment there separately. (Xero: paid Status field can be pushed by default — turn on Display-only payments to suppress it.)
- •Status changes. Cancelling, deleting, or moving an invoice to Trash in CoPortal doesn't change anything in QB/Xero/Sage. Handle there separately.
- •Partial payments. Outbound is full-invoice only. Inbound (QB) is partial-aware.
Quotes / estimates sync
Same pattern as invoices, with three quote-specific notes:
- •Multi-option quotes sync only the accepted tier. Until acceptance, the headline subtotal goes as a single estimate.
- •Lead-linked quotes (quote attached to a lead, not a client) skip auto-sync. Many leads never become clients — we don't want to pollute your accounting software with speculative customers and estimates.
- •Status mapping:
- CoPortal Sent → QB Pending / Xero Sent / Sage Sent
- CoPortal Accepted → all map to Accepted
- CoPortal Declined → QB Closed / Xero Declined / Sage Declined
QuickBooks — under the hood
The QB integration is the most exercised in CoPortal — this section spells out exactly how it works so you can debug, audit, or just understand a sync that's misbehaving. Code lives in src/lib/quickbooks.ts.
Connection storage and OAuth
- •One row per workspace in
quickbooks_connections, withrealm_id(QB's company identifier),company_namefor display, encryptedaccess_tokenandrefresh_token, andtoken_expires_at. - •Tokens are encrypted at rest with AES-256-GCM using the workspace token-encryption key (
QB_TOKEN_ENC_KEY). Decryption happens only at request time, in memory. - •Token refresh is lazy: when a request is about to fire and
token_expires_atis within 60 seconds of now, CoPortal POSTsgrant_type=refresh_tokentohttps://oauth.platform.intuit.com/oauth2/v1/tokens/bearer, persists the new pair, then continues the original request. No background refresh loop. - •If refresh fails (revoked from the QB side, network blip, etc.), the helper returns null and the calling code surfaces “QuickBooks not connected” — user reconnects from Settings → Integrations.
- •Every error response includes Intuit's
intuit_tidrequest ID — logged with the error message and worth quoting on any Intuit support ticket.
Sandbox vs production
The base URL is selected by the QB_SANDBOX env var:
QB_SANDBOX !== 'false' → https://sandbox-quickbooks.api.intuit.com/v3/company QB_SANDBOX === 'false' → https://quickbooks.api.intuit.com/v3/company
Production needs a separate Intuit OAuth app + Intuit's app-review approval. Sandbox is what most installs run against during development.
The five queries CoPortal sends
QB Online exposes a SQL-ish query language at /query?query=<urlencoded-string>. The complete set CoPortal uses:
-- Customers (preview-import + outbound match) SELECT Id, DisplayName, PrimaryEmailAddr FROM Customer MAXRESULTS 1000 -- Customer-by-email lookup (outbound sync) SELECT Id FROM Customer WHERE PrimaryEmailAddr = '<safe-email>' -- Invoices (preview + import) SELECT * FROM Invoice ORDERBY MetaData.CreateTime DESC MAXRESULTS 1000 -- Estimates (preview + import) SELECT * FROM Estimate ORDERBY MetaData.CreateTime DESC MAXRESULTS 200 -- Items (for the "Services" item we attach line items to) SELECT Id, Name, Description, Type, UnitPrice FROM Item MAXRESULTS 1000
Single quotes inside the email value are doubled (O'Brien becomes O''Brien) — that's QQL's string-escape rule.
STARTPOSITION.Outbound: the wire format CoPortal posts
Each CoPortal invoice line maps to a SalesItemLineDetail:
{
"Id": "1",
"Amount": 5000,
"DetailType": "SalesItemLineDetail",
"Description": "Web design — homepage refresh",
"SalesItemLineDetail": {
"ItemRef": { "value": "<service-item-id>" },
"Qty": 1,
"UnitPrice": 5000,
"TaxCodeRef": { "value": "TAX" } // or "NON" when vat_rate is 0
}
}The full invoice payload:
{
"CustomerRef": { "value": "<qb-customer-id>" },
"DocNumber": "INV-0042",
"TxnDate": "2026-04-30",
"DueDate": "2026-05-30",
"Line": [...],
"PrivateNote": "[Draft — review in QuickBooks before sending]"
// PrivateNote is only set when "Sync as draft" is on
}The “Services” item gymnastics
QB requires every line to point at an Item record, even for one-off service descriptions. CoPortal calls ensureServiceItem() once per sync:
- Run QQL
SELECT Id, Name FROM Item WHERE Type = 'Service' MAXRESULTS 1— returns the first service-type item in the books. - If none exists, POST
/itemwith{ Name: "Services", Type: "Service", IncomeAccountRef: { name: "Services" } }and use the returned Id.
So all CoPortal-pushed lines point at one item — usually called “Services”, or whatever your first service item happens to be. The per-line description carries the actual line text. If you want lines split across multiple QB items (different service categories, billable vs non-billable, etc.), edit on the QB side after sync; CoPortal doesn't map line-level item types yet.
Outbound customer matching
ensureQBCustomer() runs on every invoice / quote sync:
- If the CoPortal client already has
qb_customer_id: GET/customer/<id>. If the customer still exists, use it. - Otherwise, search by email (the QQL above). If found, write the QB Customer Id back onto the CoPortal client and use it.
- Otherwise, POST
/customerwith{ DisplayName: client.name, PrimaryEmailAddr: { Address: client.email } }and write the new Id back.
The write-back is the important bit — every successful sync turns the next one into a one-call lookup.
Update concurrency: SyncToken
QB requires a SyncToken on every update to prevent lost-update races. CoPortal handles this transparently:
- If the CoPortal invoice already has
qb_invoice_id, GET/invoice/<id>first to read the current SyncToken. - POST
/invoicewith the new payload +Id+SyncToken+sparse: true.
sparse: true means “only update the fields I sent” — anything QB-side that CoPortal didn't touch (custom fields, tags, departments, classes you added in the QB UI) is left alone. So editing an invoice in CoPortal and re-syncing doesn't blow away the QB-side metadata.
Tax-code handling
CoPortal posts TaxCodeRef: { value: "TAX" } when vat_rate > 0 on the CoPortal invoice, { value: "NON" } otherwise. TAX and NON are QB Online's conventional codes — QB resolves TAX to whatever default tax rate the workspace is configured for. This works because:
- •Most QB Online workspaces have one default tax rate (15% VAT in SA, varies in US states).
- •QB tax codes aren't portable across workspaces (workspace-local IDs), so we can't hard-code a specific code the way Xero allows.
- •If your books use tiered tax codes (different rates per region or service category), the imported / pushed invoice lands at the workspace default — adjust on the QB side.
Compare to Xero, which requires a specific TaxType code per region (covered below). QB's loose model is easier to wire; Xero's strict model is more accurate when you have it set up right.
Inbound: status derivation
For each imported invoice, the status pill in the preview comes from:
if (TxnStatus === 'Voided') → voided if (TxnStatus === 'Draft') → draft if (Balance ≤ 0.005) → paid if (Balance < TotalAmt - 0.005) → partial else → unpaid
The 0.005 epsilon catches rounding noise (e.g. R0.001 floating-point residual that would otherwise show as “not quite paid”).
Partial-payment mirroring
For each imported invoice with TotalAmt - Balance > 0, CoPortal synthesizes a payment-history row:
INSERT INTO invoice_payments ( invoice_id, workspace_id, amount, paid_at, method, reference, note ) VALUES ( <new-invoice-id>, <ws>, total - balance, TxnDate, 'qb_import', 'QB <DocNumber>', 'Imported partial payment from QuickBooks' )
If balance ≤ 0.005 (fully paid), the invoice is also flipped to status = paid with paid_at set. So a R10 000 invoice with R5 000 already paid in QB lands as a CoPortal invoice with a R5 000 payment row attached and an R5 000 outstanding balance — not a phantom R10 000 outstanding.
Invoice number preservation
Each imported invoice number is constructed as QB-<DocNumber> (or QB-<Id> if DocNumber is blank). On collision with an existing CoPortal invoice number, the importer appends -2, then -3, etc.:
base = "QB-1042" if "QB-1042" exists → try "QB-1042-2" if that exists too → "QB-1042-3" ...
Never silent overwrite — every successful import gets a fresh row. The collision case typically only shows up after a wipe-and-reimport cycle.
Date preservation
The CoPortal created_at is set to the QB TxnDate (parsed to UTC midnight). Implications:
- •Analytics revenue charts bucket the imported invoices on their original month — not import day. Day-1 of a QB rollover doesn't look like a R-million revenue spike.
- •The Outstanding accounts view ages invoices from issue date.
- •
sent_atis set to the same timestamp when the invoice came across as not-draft and not-voided.
Reminders
reminders_enabled defaults to false on import, regardless of paid status. This stops CoPortal's reminder cron (/api/cron/reminders) from firing on imported invoices in parallel with QB's own dunning. Toggle on per-invoice from the editor if you want CoPortal to take reminder duty for a specific one.
If the import preview's Auto-send toggle is on, reminders_enabled is set to true on import — you've explicitly told CoPortal it owns these invoices.
Estimate (quote) import
Same pattern as invoices, with three differences:
- •MAXRESULTS cap is 200 (not 1000) — Intuit's API limit for estimates.
- •Status mapping:
Draft → draft,Pending → sent,Accepted → accepted,Closed → declined. - •Number prefix:
QB-EST-<padded-counter>rather than the QB DocNumber. CoPortal proposal numbers are zero-padded sequence numbers, and QB DocNumbers tend to collide across invoice and estimate spaces in the same workspace.
OAuth callback flow
On /api/quickbooks/callback, CoPortal:
- Exchanges the auth code at the Intuit token endpoint
- Persists
realm_id, encryptedaccess_token+refresh_token, andtoken_expires_at - Fetches the company name (
SELECT * FROM CompanyInfo) for the Integrations-page badge - Runs no automatic sync at connect time — there's no surprise mass-write. The user explicitly clicks Preview customers / Preview invoices when ready.
Disconnecting
Disconnecting from Settings → Integrations deletes the quickbooks_connections row. CoPortal does not call Intuit's revoke endpoint — for full revocation, also remove CoPortal from QB → Settings → Apps. The qb_customer_id / qb_invoice_id / qb_estimate_id columns on CoPortal records stay populated; harmless metadata until/unless you reconnect (in which case existing links resolve straight away if the QB side hasn't changed).
Xero — technical notes
Outbound only today (no inbound import yet). Mechanics mirror QB closely; the key differences:
- •API base:
https://api.xero.com/api.xro/2.0. Token endpoint:https://identity.xero.com/connect/token. Same OAuth2 + AES-256-GCM at rest + 60 s refresh buffer as QB. - •Tenant model: a single Xero login can grant access to multiple tenants. CoPortal stores the tenant chosen at OAuth time in
xero_connections.tenant_idand sendsXero-tenant-id: <tenant_id>on every request. To switch, disconnect + reconnect. - •Endpoints:
GET/POST /Contacts,GET/POST /Invoices. Update model is “POST with the same Id” — no SyncToken equivalent. - •Match rules: same as QB — by
xero_contact_id, then by email, then create.
Tax codes (the big difference vs QB)
Xero requires a specific TaxType code that exists in the connected tenant. CoPortal maintains a per-region map and picks the right code based on the workspace country:
Cross-country setups (e.g. UK workspace connecting a SA Xero tenant) can post a TaxType the tenant doesn't recognise — surfaces as a sync error from Xero. The fix: connect a same-country tenant, or extend the map in src/lib/xero.ts.
Sage Business Cloud — technical notes
Outbound only. Same OAuth2 + encryption story.
- •API:
https://api.accounting.sage.com/v3.1. Token URL:https://oauth2-proxy.sageone.com/token. - •Connection: per-workspace
sage_connectionsrow withbusiness_id, encrypted tokens, plus asage_tax_rate_id. Sage tax rates are per-business and discovered on connect. - •Endpoints used:
/contacts,/sales_invoices,/sales_quotes. - •Update model: Sage uses
PUT /sales_invoices/<id>for updates (vs QB's POST-with-SyncToken pattern). No concurrency token, so the last writer wins — race risk is theoretically higher but in practice CoPortal's the only writer for invoices it manages. - •Pagination: 200 contacts per page (the API max). The contact-by-email lookup uses
?email=<url-encoded>&items_per_page=5for speed.
Microsoft Business Central — technical notes
Outbound only. Microsoft Dynamics 365 Business Central uses Azure AD multi-tenant OAuth.
- •API:
https://api.businesscentral.dynamics.com/v2.0/<envName>/api/v2.0/companies(<companyId>)/...— paths are scoped per environment + company, both captured at OAuth time. - •Token endpoint:
https://login.microsoftonline.com/common/oauth2/v2.0/token(Azure AD multi-tenant common endpoint). - •Scope:
https://api.businesscentral.dynamics.com/.default offline_access.offline_accessis what gets you the refresh token — without it the connection lasts ~1 hour and dies. - •Connection: per-workspace
bc_connectionsrow storesenvironment_name,company_id, encrypted tokens. Switching environment / company means disconnect + reconnect.
Where to look when something looks wrong
- •Sync logs: server-side console errors include the integration name and the raw error body. For QB specifically, the
intuit_tidrequest ID is in every error message — quote it on Intuit support tickets. - •Per-invoice sync state:
qb_invoice_id+qb_synced_atlive on theinvoicesrow (and equivalents for Xero / Sage / BC). Ifqb_synced_atis older than the CoPortalupdated_at, the latest CoPortal edits haven't pushed yet — click Sync. - •Connection health: Settings → Integrations shows a green “Connected as <company>” line per provider. If a token is unrecoverable (revoked QB-side, expired refresh, etc.), the badge flips red and the user is prompted to reconnect.
- •Audit log: Settings → Audit log records sync-relevant events with actor, entity, and timestamp.
- •Reasons map (importer): the import response includes a
reasonsobject counting why each row was skipped (already_imported,no_client_link,missing_customer_data,docnumber_conflict) — surfaced in the success toast after a bulk import.
Best-practice workflows
First-time setup (existing customers + invoices in QB)
- 1Connect QuickBooks from Settings → Integrations.
- 2Run Preview customers. Importing tags clients and links existing CoPortal clients to the QB customers automatically (where emails match). For genuine duplicates that share an email — only tick to create separate rows when they really are different entities.
- 3Run Preview invoices. Tick the auto-send toggle ONLY if you want CoPortal to send the unpaid ones to your customers — usually leave it off because they already got the QB email.
- 4Eyeball the dashboard / analytics — paid + outstanding totals should match what QB shows for the same period, modulo any in-flight changes.
- 5Set per-integration toggles: Auto-sync = on so future invoices push immediately, Sync-as-draft = on for the first month if you want a safety net (review in QB before they're sent there).
Day-to-day (CoPortal-first, recommended)
- 1New client comes in → add them in CoPortal.
- 2Quote them in CoPortal (single-line or multi-option). Send. They sign.
- 3Convert quote to invoice (or generate a deposit / milestone invoice). Send.
- 4Payment lands → record it on the invoice (CoPortal also auto-marks paid when a Paystack charge succeeds).
- 5CoPortal auto-syncs the invoice + payment status to your accounting software. Go to QB / Xero / Sage to formally reconcile the payment against your bank feed there. CoPortal's job ends at "delivered + paid"; the accounting software's job is the legal ledger.
Handling duplicate clients
If you end up with two CoPortal clients for the same real-world customer (typically because one was created manually before connecting QB and the other came from QB import), use the merge tool: Clients → Duplicates (top-right of the clients page).
- •The page auto-detects clients sharing an email and groups them.
- •Each group shows side-by-side stats (projects, invoices, paid, outstanding, external links) so you can pick the winner consciously.
- •The most-active row is preselected as winner — usually the one to keep.
- •Merging moves all projects, invoices, quotes, and contacts onto the winner. The loser is soft-deleted (recoverable from Trash for 30 days).
- •External accounting IDs from the loser fill in any gaps on the winner. If both are linked to different external customers, CoPortal stops and asks you to resolve in your accounting software first — never silently overwrites a working link.
Reconciling discrepancies
If a number in CoPortal doesn't match what you see in your accounting software, work this checklist top-down:
- Look at the affected invoice in CoPortal — is the last synced at timestamp recent? If not, click Sync manually to push the latest version.
- Open the invoice in the accounting software — does the line-item subtotal match? If not, the invoice was probably edited in only one of the two places. Pick the source of truth (CoPortal-first → re-sync; accounting-first → import the updated one).
- Check payments on both sides. CoPortal's “Total paid” should match the sum of payments recorded in the accounting software for that invoice. Mismatches mean someone recorded a payment in only one place.
- Tax/VAT mismatches: check the workspace VAT setting (Settings → Invoice & Tax) matches the rate used on the accounting-side invoice.
Auto-sync, auto-send, display-only, and reminders — toggle decoder
Easy to confuse. Quick decoder:
Edge cases worth knowing
- •Customer deleted in QB after import. CoPortal's client row stays — we don't auto-delete to avoid losing project history. The next outbound sync for that client will fail with a customer-not-found error; clear the
qb_customer_idon the CoPortal client (Edit → save with the field blank) to force a fresh customer match on next sync. - •Invoice voided in QB. CoPortal doesn't see this — refresh by re-running the invoice import preview, where the invoice will show as already imported but you can manually cancel the CoPortal copy.
- •QB sandbox vs production. Connection is one-or-the-other per workspace. Switching environments means disconnecting and re-connecting; existing external ID links stay on the CoPortal records but won't resolve in the new environment until you re-link.
- •OAuth tokens expire. If a sync starts failing with a 401, reconnect the integration from Settings → Integrations. Tokens are encrypted at rest with AES-256-GCM and auto-refreshed in the background; expired tokens usually mean you revoked CoPortal access from the accounting software directly.
- •Multi-currency. Cross-region invoices (workspace in one country, client in another) sync the invoice currency through to QB / Xero. CoPortal also captures an FX-rate snapshot at issue time so your reporting can show the local-currency equivalent if needed.