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 #
POST /api/v1/credit-notes/{id}/publish?entity_id=…validates the gates above and enqueues aCREDIT_NOTEresource withIntegrationSyncResourceExtra::CreditNotethrough the sharedIntegrationSyncEnqueueService. A 200 means queued, not published.bonsai-accounting-syncdispatches the resource toservice/credit_notes, which implements the genericExtractionSyncResourcepipeline (same as invoices and direct expenses): prepare a newcredit_note_datasnapshot withaction_type = PUBLISHand aSYNCINGactivity, validate and upload to the provider, persist the external ID, upload the PDF attachment, then record the finalSYNCED/ERRORactivity.- Reads and writes from the worker go through two organization/entity scoped internal endpoints:
GET /internal/api/v1/credit-notes/{id}/sync-dataandPOST /internal/api/v1/credit-notes/store. - The webapp shows progress via the accounting sync tracker (resource type
creditNote) and history fromGET /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 ignoresReferenceon ACCPAYCREDIT) and to the QBOPrivateNote(with reason and notes). Neither links or applies the credit. Xero has no notes field, sonotes/reasonare not sent to Xero. Line items carry XeroItemCode/ QBOItemRefwhen an item is selected; header tags become the QBO transaction-levelClassRef(first tag), line tags become XeroTracking/ QBO lineClassRef.
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.sqladdsCREDIT_NOTEtointegration_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) andtrg_sync_credit_note_data_to_extraction_data_upd, mirroring the invoice triggers, soextraction_data.credit_note_data_publish_status/_publish_sync_statusrefresh 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-syncbefore exposing the API/UI capability. An older worker that does not knowCREDIT_NOTEwould 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.