Skip to content

fix(netsuite): skip sync of line-less invoices - #6073

Open
lago-claude-ai-agent[bot] wants to merge 1 commit into
mainfrom
fix/claude-netsuite-skip-line-less-invoice-sync
Open

lago-claude-ai-agent[bot] wants to merge 1 commit into
mainfrom
fix/claude-netsuite-skip-line-less-invoice-sync

Conversation

@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor

Finalized invoices whose fees are all zero-amount were still posted to NetSuite, which drops zero-amount lines, ends up with no line item, and rejects the transaction with a 424. That status counts as retryable, so the job retried three times and sent a sync_error webhook on each attempt for an invoice with nothing billable to sync.

Invoice payloads now expose a sync_allowed? predicate defaulting to true, and CreateService returns early (as a success, not an error) when it is false. NetSuite overrides it to mirror the lines that actually survive on its side — positive fees or discounts. Xero, Anrok and HubSpot keep the default, so Xero's deliberate zero-amount line syncing is unchanged. This also makes a manual invoice sync a clean no-op for these invoices.

Deliberately out of scope: the 424 retryable classification stays as is, since the request is no longer sent at all.

## Context

Finalized invoices whose fees are all zero-amount were still posted to
NetSuite. NetSuite drops zero-amount lines, ends up with no line item at
all, and rejects the transaction with an HTTP 424. That status is treated
as retryable, so the job retried three times and delivered a sync_error
webhook on every attempt, for an invoice that has nothing billable to
sync.

The decision is provider-specific and cannot live at enqueue time: Xero
deliberately syncs zero-amount lines, so a shared guard would change its
behavior.

## Description

Invoice payloads now expose a `sync_allowed?` predicate that defaults to
true, and the create service returns early, without an error, when it is
false. NetSuite overrides it to mirror the lines that actually survive on
its side: positive fees or discount lines. Xero, Anrok and HubSpot keep
the default, so their behavior is unchanged.

The zero-amount fee filter shared by the payload body and the new
predicate is extracted into a single place, and the payload instance is
memoized now that the create service references it more than once.

The 424 retryable classification is left alone: the fix stops the request
from being sent in the first place.

Signed-off-by: lago-claude-ai-agent[bot] <297187938+lago-claude-ai-agent[bot]@users.noreply.github.com>
@lago-claude-ai-agent

Copy link
Copy Markdown
Contributor Author

PASS — the guard matches the lines that actually survive on NetSuite's side, and it is scoped to NetSuite only, so no other provider changes behavior.

Verified:

  • Netsuite#sync_allowed? mirrors body's fee_items + discounts: when no positive fee exists, fees falls back to all-zero lines that NetSuite drops, leaving only discounts — so positive_fees.exists? || discounts.any? is exactly "at least one line survives".
  • Xero, Anrok and HubSpot inherit the true default and none override sync_allowed?, so Xero's deliberate zero-amount line syncing (fix(intergrations): Fix fees in invoices payload #2656) is untouched.
  • No sibling write path was missed: invoice sync to accounting providers only goes through Invoices::CreateService (there is no invoice UpdateService; ReconcileService is a by-tranid GET). The SyncIntegrationInvoice mutation calls the same service, so a manual sync is a no-op too.
  • The guard sits after the integration_invoice check and before throttle!, and returns a plain success like the sync_invoices guard above it — no error webhook, no throttle slot burned.
  • Memoizing payload is safe: fee_items rebuilds its hashes per call, so nothing carries over between sync_allowed? and body.
  • The CreateService spec is a genuine regression test — the post_with_response mock is set up to succeed, so without the guard the request would be sent and the assertion would fail.

Nits (non-blocking):

  • positive_fees is a fresh relation each call, so sync_allowed? adds one EXISTS query before the sync; discounts is likewise recomputed once more (cheap — the collection mappings come from an already-loaded association).
  • The default-true test instantiates BasePayload directly; asserting it on Xero would tie the guarantee to the class that actually runs in production.

@lago-claude-ai-agent
lago-claude-ai-agent Bot marked this pull request as ready for review August 4, 2026 14:22
@osmarluz
osmarluz self-requested a review August 4, 2026 14:25
@osmarluz osmarluz self-assigned this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant