TL;DR: I built a QuickBooks Online integration for a multi-tenant proposal-to-billing SaaS. When a client pays through Stripe, it maps the customer and services, synchronizes the invoice and payment, and later reconciles the payout as a QuickBooks deposit, including processing fees and recovery from inconsistent remote state. Those lessons then shaped an event-driven integration engine: domain services publish neutral business events, while provider-specific listeners decide how each connected integration should react.
The Product Problem
Accounting firms use our platform to send proposals, collect payments, and manage recurring client work.
Without an integration, every payment creates manual bookkeeping:
- Find or create the customer in QuickBooks
- Recreate the invoice with the correct services
- Record the payment against that invoice
- Wait for the Stripe payout
- Create the corresponding bank deposit
- Account for Stripe processing fees
That is repetitive work, but automating it is not a matter of forwarding one webhook.
Stripe knows about payment intents, balance transactions, and payouts. Our platform knows about proposals, invoices, clients, and organizations. QuickBooks knows about customers, items, invoices, payments, Undeposited Funds, and deposits.
The integration has to translate between all three without duplicating money or leaving their state inconsistent.
The End-to-End Flow
The implemented workflow follows the accounting lifecycle rather than treating “payment succeeded” as the end:
Client pays through Stripe
│
▼
Internal invoice is collected
│
▼
QuickBooks customer and items are resolved
│
▼
QuickBooks invoice is created or updated
│
▼
QuickBooks payment is linked to the invoice
│
▼
Stripe payout settles
│
▼
QuickBooks deposit is created and reconciled
The payment and deposit are separate stages. That distinction became one of the most important design decisions.
Mapping Internal Data to QuickBooks
Our domain and the QuickBooks domain do not use the same identifiers.
An internal client needs a corresponding QuickBooks customer. Services sold through a proposal need QuickBooks items. Every synchronized invoice, payment, and deposit needs a remote identifier that can be reused safely on retries.
The integration therefore maintains explicit mappings and synchronization records rather than repeatedly searching by display name.
When an entity has not been linked yet, the integration attempts to resolve or create the required QuickBooks record. When a mapping is stale or invalid, the failure is recorded with enough context to correct it.
This avoids a dangerous assumption:
A successful operation once does not mean the remote identifier will remain valid forever.
Users can rename, merge, archive, or delete entities directly in QuickBooks. A production integration must expect remote state to change independently.
Synchronizing Invoices and Payments
Invoice synchronization has to preserve more than a total amount.
The integration resolves:
- The QuickBooks customer
- Service and item mappings
- Quantities and prices
- Tax treatment
- Existing remote invoice state
- Whether the operation is a create, update, or recovery
Guard checks run before synchronization. If QuickBooks is disconnected, the customer cannot be linked, or required accounting configuration is missing, the integration records a meaningful failure instead of attempting an incomplete write.
Once the invoice exists in QuickBooks, the payment can be recorded against it. The resulting remote identifiers are persisted so retries do not blindly create duplicate records.
This is where idempotency becomes a product requirement, not just an implementation detail. Payment webhooks can be delivered more than once, jobs can restart, and users can manually retry a failed sync.
Why Deposits Are a Separate Workflow
Stripe payments do not map one-to-one to bank deposits.
Multiple client payments can be grouped into a single Stripe payout. In QuickBooks, those payments are initially associated with Undeposited Funds. When the payout settles, the integration creates the deposit that moves the grouped payments to the configured bank account.
To build that deposit, the system needs to reconcile:
- Payments linked to synchronized invoices
- The gross payout amount
- Stripe processing fees
- Missing or unmatched invoice amounts
- Differences between stored data and Stripe-reconciled totals
I extracted the deposit-line calculation into a focused function:
const result = await buildQboDepositLines({
amountSource,
depositAmounts,
depositAmount,
groupInvoices,
paymentIdsByGroupInvoiceId,
organization,
getClientTaxRate,
});
It produces the linked payment lines, fee lines, and any required adjustment lines before the QuickBooks request is sent.
Keeping this arithmetic separate from network orchestration made it possible to test reconciliation rules without depending on QuickBooks.
Representing Stripe Fees Correctly
The amount charged to a client and the amount deposited into the firm’s bank account are not always equal.
Stripe subtracts processing fees from the payout. If the integration records only the gross payment, the QuickBooks deposit will not match the amount that reached the bank.
The deposit builder adds fee entries against the organization’s configured expense account and reconciles the final line total with the expected payout amount.
That produces an accounting representation with:
- The client payment
- The processing expense
- The resulting net deposit
This detail is easy to miss in a demo integration and impossible to ignore in production.
Recovering From Existing Remote State
One of the hardest QuickBooks cases is error 6000.
In the deposit flow, it can indicate that a payment already belongs to another deposit. Retrying the same create operation does not solve the problem; it can repeatedly fail even though QuickBooks already contains the financial relationship we wanted.
The integration treats that condition as a reconciliation problem:
- Detect the QuickBooks business-validation error
- Search the remote state for the existing relationship
- Confirm that the expected payments are already represented
- Mark the local synchronization record accordingly
That distinction matters:
- Some failures should be retried
- Some require user configuration
- Some mean the remote operation already succeeded
- Some are genuinely unexpected
A generic “QuickBooks sync failed” message throws away the information required to recover safely.
Making Failures Visible
External integrations fail for reasons outside the application:
- Expired or revoked authorization
- Missing customer or item mappings
- Invalid QuickBooks configuration
- Provider rate limits
- Remote entities changed by a user
- Partial work completed before a process stopped
The integration persists synchronization status and translates provider failures into technical and user-facing context.
This allows the product to answer:
- Which entity failed?
- At which synchronization stage?
- What did QuickBooks return?
- Can the operation be retried?
- Does the user need to change a mapping or setting?
The goal is not to pretend failures disappear. It is to make them observable and recoverable.
What QuickBooks Taught Me About Integration Boundaries
The first production version concentrated most orchestration in a single NestJS integration service. As the QuickBooks surface expanded, that service grew to roughly 1,700 lines.
Rather than replace the working integration in one rewrite, I started extracting boundaries around behavior already understood:
| Component | Responsibility |
|---|---|
QboClientService | QuickBooks client access and shared provider setup |
QboInvoiceSyncService | Invoice and payment synchronization |
QboDepositSyncService | Payout and deposit reconciliation |
QboSyncRecordService | Persistent synchronization status |
QboErrorService | Provider failure handling |
buildQboDepositLines | Deterministic deposit-line calculation |
The original orchestrator still exists and still coordinates legacy paths. The integration is being decomposed progressively, with tests around the accounting behavior that carries the highest risk.
The important outcome is not a smaller file by itself. It is making customer mapping, invoice synchronization, deposit reconciliation, and failure handling independently understandable and safer to change.
But the most important lesson appeared when we added another provider.
QuickBooks models accounting. An external CRM models contacts and opportunities. Trying to force both behind the same provider methods would create an interface so generic that it explained neither integration.
The reusable boundary was not the provider API. It was the business event that should cause an integration to react.
Building the Event-Driven Integration Engine
Instead of making client and proposal services call the CRM directly, I introduced neutral domain events with NestJS EventEmitter2:
export const CLIENT_CREATED = "client.created";
export const PROPOSAL_CREATED = "proposal.created";
export const PROPOSAL_UPDATED = "proposal.updated";
The domain service publishes what happened:
ClientService ──────────► client.created
ProposalService ────────► proposal.created
ProposalService ────────► proposal.updated
Provider-specific modules subscribe independently:
┌──────────────────────┐
client.created ─────────►│ CRM listener │──► create/update contact
proposal.created ───────►│ │──► create opportunity
proposal.updated ───────►│ │──► update opportunity
└──────────────────────┘
The client service does not know whether an organization uses a CRM. It publishes the business event once. The integration listener checks the organization’s configuration and reacts only when that provider is connected.
Each organization’s provider configuration is stored through a shared Integration record, while credentials and provider behavior remain inside the provider module.
That created an integration engine with three distinct responsibilities:
| Layer | Responsibility |
|---|---|
| Domain services | Publish business facts without importing provider code |
| Event catalog | Define stable events and payload boundaries |
| Provider listeners | Check configuration and translate events into provider actions |
Why This Makes the Next Integration Easier
Without the event boundary, adding a provider usually means editing core product services:
await this.quickBooks.syncClient(client);
await this.crm.syncContact(client);
await this.futureProvider.syncCustomer(client);
Every integration increases coupling and makes a normal client operation responsible for provider orchestration.
With the event-driven approach, a new provider can be introduced as a separate module:
- Register its organization-level configuration
- Subscribe to the relevant domain events
- Map the event payload into provider operations
- Persist provider-specific synchronization state
- Handle its authentication, retries, and errors locally
The core client or proposal workflow does not need another provider-specific branch.
This does not make every integration cheap. QuickBooks still requires accounting-specific reconciliation, and another provider will bring its own edge cases. What becomes cheaper is establishing the connection point: the domain already communicates what happened without knowing who consumes it.
What the Engine Does Not Guarantee Yet
The current event bus is in-process. It decouples product services from integration handlers, but it is not a durable message broker.
That distinction matters:
- It reduces source-level coupling
- It creates a repeatable provider extension point
- It does not guarantee delivery after a process crash
- It does not provide persisted retries by itself
If an integration workflow requires durable delivery across process boundaries, the next step is a transactional outbox and idempotent asynchronous consumers. That should be added for the reliability guarantee it provides, not merely to label the architecture “event-driven.”
What I Learned
Model the accounting lifecycle, not the webhook
payment_intent.succeeded is an event from Stripe. It is not the complete business workflow. Invoice, payment, and payout reconciliation happen at different times.
Persist remote identity and synchronization state
Searching the provider on every operation is not a substitute for explicit mappings and idempotent records.
Treat provider errors as domain information
An error code can represent a retryable outage, invalid configuration, or an operation that already happened. Recovery depends on knowing the difference.
Extract rules only after you understand them
The first implementation taught me where the real complexity lived. The later service boundaries came from production behavior, not from guessing a generic integration framework upfront.
Reuse events, not provider abstractions
QuickBooks and a CRM do not share the same behavior. They can still react to the same business fact. Stable domain events provide reuse without pretending every provider has an identical API.
An in-process event engine is decoupled, not durable
An in-process event engine improves extensibility, but durable delivery requires additional infrastructure. Naming that boundary explicitly prevents architectural confidence from exceeding the guarantees actually implemented.
The Product Outcome
The integration turns a Stripe payment into an accounting workflow the firm can follow in QuickBooks:
- Customer and service data are mapped
- Invoices and payments are synchronized
- Payouts become reconciled deposits
- Fees are represented explicitly
- Failures preserve enough context to recover
For the user, the value is straightforward: less duplicate data entry, clearer synchronization status, and fewer opportunities for payment records to drift between systems.
For the product, the event engine also provides a consistent place to connect future providers without adding their logic to core client and proposal services.
For me, the lesson was broader: a serious integration is not an API wrapper. It is a consistency boundary between products that were never designed to share one state machine, and an extensible integration platform starts by separating business events from provider reactions.