Credit Note Publishing (Xero, QuickBooks Online, freee, Sage, Zoho Books)

Credit Note Publishing (Xero, QuickBooks Online, freee, Sage, Zoho Books) #

tofu publishes verified AP credit notes (AP_CREDIT_NOTE) and AR credit notes (AR_CREDIT_NOTE) to Xero, QuickBooks Online (QBO), freee, Sage Business Cloud Accounting and Zoho Books. Credit notes are a separate extraction type with their own tables (credit_note, credit_note_data, credit_note_line, credit_note_data_sync_activity) and a dedicated CREDIT_NOTE integration sync resource. They never pass through invoice storage or invoice provider APIs.

Tracking: ENG-7303 (parent ENG-7285).

Gating #

Publishing a credit note requires all of the following. Each check fails before anything is queued, so a rejected request never reaches the provider.

Gate Where Failure
Entity flag ap_credit_note_enabled / ar_credit_note_enabled matching the note direction (new publishes only; cleanup retries remain available with flags disabled) bonsapi publish service 422
Active accounting integration advertising can_publish_credit_note bonsapi + worker 422 / sync activity ERROR
Extraction status not PENDING / PROCESSING / AWAITING_PROCESSING / ERROR / DELETED — same eligibility as invoices, so NEEDS_REVIEW can be published; MANUAL_CLEANUP retries the delete instead bonsapi + worker 422
Note direction matches the extraction type bonsapi store/publish 400
Note, data, lines and activity all belong to the caller’s entity and organization bonsapi internal store 400

Xero, QBO, freee, Sage and Zoho Books set can_publish_credit_note (IntegrationProvider::capabilities()); the flag is independent of can_publish and is checked on its own everywhere. Every other provider hits the trait default and returns an explicit Unimplemented error. Auto-publish on verify is not enabled for credit notes.

Flow #

  1. POST /api/v1/credit-notes/{id}/publish?entity_id=… validates the gates above and enqueues a CREDIT_NOTE resource with IntegrationSyncResourceExtra::CreditNote through the shared IntegrationSyncEnqueueService. A 200 means queued, not published.
  2. bonsai-accounting-sync dispatches the resource to service/credit_notes, which implements the generic ExtractionSyncResource pipeline (same as invoices and direct expenses): prepare a new credit_note_data snapshot with action_type = PUBLISH and a SYNCING activity, validate and upload to the provider, persist the external ID, upload the PDF attachment, then record the final SYNCED / ERROR activity.
  3. Reads and writes from the worker go through two organization/entity scoped internal endpoints: GET /internal/api/v1/credit-notes/{id}/sync-data and POST /internal/api/v1/credit-notes/store.
  4. The webapp shows progress via the accounting sync tracker (resource type creditNote) and history from GET /api/v1/credit-notes/{id}/data?action_types=PUBLISH,DELETE.

Deleting a published credit note (single delete or bulk extraction delete) queues a DELETE resource instead of deleting locally. If the remote delete fails the extraction moves to MANUAL_CLEANUP; pressing Publish again on that extraction retries the delete. Cleanup remains available when the entity’s credit-note flag is disabled. If no external ID was saved, cleanup completes locally without calling a provider delete endpoint. Missing or inactive accounting integrations return an actionable 422 before queueing.

The extraction list row exposes credit_note_external_id and credit_note_status (kept in sync by the credit_note → extraction_data trigger). The webapp reads them through getExtractionExternalId / getExtractionAccountingStatus (shared/utils/extraction-external.ts) to show the provider link and status: Xero AccountsPayable|AccountsReceivable/ViewCreditNote.aspx?creditNoteID=…, QBO app/vendorcredit|creditmemo?txnId=…, Sage invoicing/purchase_credit_notes|sales_credit_notes/…, freee reports/journals?deal_id=… (the generic deal link), Zoho Books #/creditnotes|vendorcredits/…/edit (requires books_domain in the integration metadata).

Provider mapping #

Local direction Xero QuickBooks Online freee Sage Zoho Books
AP credit note CreditNotes, Type = ACCPAYCREDIT VendorCredit, AccountBasedExpenseLineDetail deals, type = expense, negative line amounts purchase_credit_notes vendorcredits (account required per line)
AR credit note CreditNotes, Type = ACCRECCREDIT CreditMemo, SalesItemLineDetail (item required per line) deals, type = income, negative line amounts sales_credit_notes creditnotes (item or account per line)

Shared rules (libs/rust/bonsai-integration/src/accounting/credit_note.rs):

  • Contact, date and at least one line are required. Every line needs quantity and unit price. Currency is optional: like invoices and direct expenses, the provider back-fills the integration’s base currency when the extraction did not capture one.
  • The credit-note total must be positive; individual lines may be negative (e.g. a “Less: subsidy” deduction) and are sent exactly as printed. A document-signed note (total and every amount negative) is flipped to positive before mapping. Stored data is never modified.
  • Discount fields are rejected. Enter the credited amount on each line instead.
  • Credits are always created unallocated. Original invoice number goes to Xero Reference (AR only — Xero ignores Reference on ACCPAYCREDIT) and to the QBO PrivateNote (with reason and notes). Neither links or applies the credit. Xero has no notes field, so notes/reason are not sent to Xero. Line items carry Xero ItemCode / QBO ItemRef when an item is selected; header tags become the QBO transaction-level ClassRef (first tag), line tags become Xero Tracking / QBO line ClassRef.

Xero specifics: create and republish both go through POST /CreditNotes (the republish carries CreditNoteID), status is AUTHORISED. No idempotency header is sent, matching the invoice and direct-expense paths; a create whose response is lost is not deduplicated by Xero. Tracking options resolve through the same helper as invoice lines: a tag whose option or category was deleted in Xero is skipped with a warning, an archived option or two options in one category is a validation error. Attachments use the shared Xero attachment helper (POST /CreditNotes/{id}/Attachments/{name}, same size/error handling as invoices). Delete reads the current note first: DRAFT/SUBMITTED → DELETED, AUTHORISED → VOIDED, anything with allocations or payments fails with an actionable message.

QBO specifics: the request follows existing invoice preferences for multi-currency (CurrencyRef + ExchangeRate) and the US tax toggle (TAX/NON codes vs GlobalTaxCalculation). TxnTaxDetail and LinkedTxn are omitted so QBO computes tax per line. Tax-inclusive lines follow the QBO invoice/purchase mappers: Amount is the net line amount (line_amount - tax_amount) and TaxInclusiveAmt carries the gross, so GlobalTaxCalculation = TaxInclusive reconciles to the document total instead of deducting tax twice. No requestid is sent on create, matching the invoice and direct-expense paths. Republish and delete fetch a fresh SyncToken first; delete is a hard delete (?operation=delete) and is refused while the credit has LinkedTxn entries or Balance < TotalAmt (applied to an invoice/bill or refunded), mirroring the Xero allocation check.

freee specifics: freee has no credit-note object. A credit is a deal (取引) of the same type as the original document whose details[].amount / vat are negative (freee’s マイナス取引), built by FreeeDealCreateRequest::from_credit_note. Amounts are tax-inclusive integer yen exactly like invoice deals; accounts are required on every line and tax codes fall back to the account default. No due_date is sent, so the negative deal is never classified as an AP/AR invoice by the freee import path. The credit-note number (or reference) becomes ref_number; freee has no notes field. Republish deletes the deal and creates it again (should_delete_before_publish), so the external ID changes. Attachments use the shared file-box receipt helpers. Status comes from the deal: a settlement (payments[], or due_amount below amount) marks the credit PARTIALLY_PAID/PAID, which blocks republish (locked) and delete; delete is DELETE /api/1/deals/{id} and a 404 completes as already gone.

Sage specifics: POST /purchase_credit_notes / POST /sales_credit_notes reuse the invoice line mapper (SageInvoiceLineWriteBody::from_input), so ledger account, item type resolution (product vs service), analysis-type categories (max three), tax-inclusive → net conversion and the country NO_TAX rate behave exactly like invoice publishing. The supplier’s number goes to vendor_reference on purchase credit notes; sales credit notes are auto-numbered by Sage and the returned credit_note_number is stored back. Original invoice number, reason and notes are combined into notes (credit_note::combined_notes). Sales credit notes resolve tax_address_region_id from the customer for region-taxed countries (Canada) like sales invoices. Republish deletes the existing credit note and creates a new one (should_delete_before_publish), so the external ID changes; PART_PAID/PAID is locked. Delete mirrors invoices: purchase credit notes are hard-deleted (DELETED), sales credit notes are voided with void_reason (VOIDED). Attachments use PURCHASE_CREDIT_NOTE / SALES_CREDIT_NOTE attachment contexts with the shared Sage attachment helpers.

Zoho Books specifics: AR → POST /books/v3/creditnotes, AP → POST /books/v3/vendorcredits (ZohoCreditFields). Every line needs an item or an account (vendor credits always need an account); lines without an item get a name derived from the description. Stored tag ids are reporting-tag option ids, so each is resolved to {tag_id, tag_option_id} through the cached tag list (ZohoSelectedTagGroups rejects a missing/inactive option or two options of one tag); header tags publish on the request root because Zoho rejects transaction-level tags on line items, the same as invoice publishing. GCC VAT fields (tax_treatment, vat_treatment, place_of_supply) come from the cached contact like invoices. is_inclusive_tax follows the line amount type. When the document carries its own number it is sent with ignore_auto_number_generation=true, otherwise Zoho numbers the credit and the number is stored back. Zoho takes the currency from the contact, so a note whose currency differs from the contact’s default currency is rejected before publishing. Status: draft, open, closed (fully applied → PAID), void; an open credit with balance < total is PARTIALLY_PAID. Republish deletes and recreates the credit (should_delete_before_publish), delete is DELETE /{resource}/{id} refused while applied. Attachments: POST /{resource}/{id}/attachment (multipart attachment through the shared ZohoAttachmentUpload size/format checks; when Zoho omits the document id the record is re-read to find it), documents[] on the record, DELETE /{resource}/{id}/documents/{id}. Zoho 404s are mapped to NotFound so a record deleted in Zoho settles like the other providers.

Delete status check: before deleting, the worker reads the provider-side status (fetch_credit_note_status) instead of trusting the local copy — Xero maps Status plus Allocations/Payments, QBO derives it from Balance/TotalAmt/LinkedTxn. PAID / PARTIALLY_PAID blocks the delete with a validation error; a 404 completes it as already gone. Queueing the delete pins the extraction to DELETING (as invoices do), which removes it from every publish surface so a later publish cannot evict the queued delete.

Delete during publish: DELETE /credit-notes/{id} (and the generic extraction delete) returns 409 Conflict while a credit-note publish for that extraction is still queued or running, because a local delete at that point would leave the provider credit behind. Once the publish settles the delete either queues a remote cleanup (external ID stored) or completes locally. If the accounting integration is missing, inactive, or no longer advertises can_publish_credit_note, the delete is rejected with 400 Bad Request (a local delete would orphan the provider credit); skip_external_delete is the deliberate escape hatch.

Locked credits: republishing a credit note whose provider status is PAID / PARTIALLY_PAID (allocated or refunded) is skipped the same way locked invoices are — the extraction stays verified and a SYNCED activity explains that changes were not pushed. When a remote delete fails, an optimistic local DELETED/VOIDED status is restored to the status the note had before the delete.

Internal store replay: the worker’s HTTP client retries transport failures. POST /internal/api/v1/credit-notes/store therefore treats a sync activity whose ID already exists as an acknowledgement rather than a primary-key failure.

Database and rollout order #

  • Migration 20261002020403_credit_note_lifecycle.sql adds CREDIT_NOTE to integration_sync_resource_type. It is additive and must be applied before any bonsapi that can enqueue the new resource.
  • The same migration adds trg_sync_credit_note_data_sync_activity_to_extraction_data (+ _upd) and trg_sync_credit_note_data_to_extraction_data_upd, mirroring the invoice triggers, so extraction_data.credit_note_data_publish_status / _publish_sync_status refresh when the worker writes a SYNCING/SYNCED/ERROR activity or updates the data row in place. Without them the list row (and the sync panel badge) stayed on the status computed when the data row was first inserted.
  • Deploy bonsai-accounting-sync before exposing the API/UI capability. An older worker that does not know CREDIT_NOTE would treat the resource as unsupported.
  • Pilot: after the sandbox checklist in the accounting-sync doc passes, enable the existing AP/AR credit-note entity flags for approved pilot entities only, and monitor credit-note sync activity for one week before GA.

Verification #

mise exec -- cargo test -p bonsai-integration --lib credit_note
mise exec -- cargo test -p bonsai-accounting-sync --lib credit_note
mise run rust-test -- --no-doppler -p bonsapi -E 'test(credit_note)'
mise run rust-test -- --no-doppler -p bonsai-accounting-sync

Webapp: integration.test.ts, credit-note-adapter.test.ts and invoice-sync-info.test.tsx cover capability gating, adapter metadata and the publish button state for credit notes.

The bonsapi credit-note suite also covers queueing both directions for Xero/QBO, validation gates, cleanup with disabled flags, tenant isolation, sync history and transaction rollback on invalid activity ownership. These commands must be run before claiming sandbox or pilot readiness.