Skip to content

OUT-3909: integration tests for invoice.paid webhook event - #260

Merged
SandipBajracharya merged 4 commits into
masterfrom
OUT-3909
Jun 29, 2026
Merged

OUT-3909: integration tests for invoice.paid webhook event#260
SandipBajracharya merged 4 commits into
masterfrom
OUT-3909

Conversation

@SandipBajracharya

Copy link
Copy Markdown
Collaborator

Summary

Adds integration tests for the invoice.paid QuickBooks webhook event, covering every reachable branch of the handler. Mirrors the existing paymentSucceeded suite conventions. Closes OUT-3909.

The tests drive the real route via next-test-api-route-handler against a testcontainers Postgres, asserting DB state (qb_sync_logs, qb_invoice_sync) and Intuit/Copilot mock calls.

Branches covered

Test Scenario
happyPath QB payment created, invoice flipped to PAID, log stores the QBO Payment id
idempotency Re-delivery short-circuits at the claim; no second payment
invoiceNotFound Invoice missing from sync table → FAILED log
missingCustomerId Synced invoice has no linked customer → FAILED log
createdLogMissing No invoice.created log to read the amount from → FAILED log
createdLogPending invoice.created log still PENDING → FAILED log
qbCreatePaymentFails QB rejects the payment → FAILED log, invoice stays OPEN

Helpers

  • seedInvoiceCreatedLog — reusable seeder for the prerequisite CREATED log (amount in cents).
  • TEST_QB_PAYMENT_ID — new constant, wired into the shared Intuit mock's createPayment so the happy-path assertion is compile-linked to the mock.

Notes

  • quickbooks_id for INVOICE/PAID stores the QBO Payment id (polymorphic column) — pinned in happyPath.
  • The "soft-deleted customer mapping" branch was explored but dropped: a FK on qb_invoice_sync.customerId makes an orphaned customer impossible, leaving only the narrow soft-delete path.

Verification

  • invoicePaid suite: 7/7 pass
  • Full integration project: green
  • lint:check (0 errors) + prettier:check clean

🤖 Generated with Claude Code

Cover every reachable branch of the invoice.paid handler: happy path,
idempotent re-delivery, invoice not synced, invoice without a linked
customer, missing invoice.created log, PENDING invoice.created log, and
QB createPayment failure.

Add a reusable seedInvoiceCreatedLog helper and a TEST_QB_PAYMENT_ID
constant wired into the shared Intuit mock's createPayment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 26, 2026

Copy link
Copy Markdown

OUT-3909

@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quickbooks-sync Building Building Jun 26, 2026 12:26pm
quickbooks-sync (dev) Ready Ready Preview, Comment Jun 26, 2026 12:26pm

Request Review

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds 7 integration tests for the invoice.paid webhook handler and splits a single combined error check in invoice.service.ts into two distinct checks with separate error messages ('Invoice sync log not found' vs 'Invoice sync log still pending'), making the two failure modes distinguishable in FAILED logs.

  • Production change: webhookInvoicePaid now has distinct throw Error(...) messages for the missing-CREATED-log and PENDING-CREATED-log branches, directly addressing the previous review feedback about ambiguous error strings.
  • Test suite: Seven tests cover the full branch matrix (happy path, idempotency via claimWebhookEvent, invoice not found, null customerId, missing/pending CREATED log, and QB payment rejection), each verifying DB state in qb_sync_logs and qb_invoice_sync plus mock call counts.
  • Helpers: seedInvoiceCreatedLog and TEST_QB_PAYMENT_ID are new shared fixtures that keep mock return values compile-linked to seed constants.

Confidence Score: 5/5

Safe to merge — the production change is a straightforward error-message split with no behavioral impact, and all seven integration tests are correctly wired to the real route and a live DB.

The only change to production code is separating one combined conditional into two sequential guards with distinct error strings. Every affected branch is now covered by a dedicated integration test that drives the real route handler and asserts DB state. No logic, schema, or data-flow changes are present.

No files require special attention. The minor suggestion on happyPath.test.ts is about making an existing assertion more precise, not a correctness issue.

Important Files Changed

Filename Overview
src/app/api/quickbooks/invoice/invoice.service.ts Splits the combined !invoiceLog
test/integration/quickbooks/invoicePaid/happyPath.test.ts Covers the success path well, but the fixture total and seeded log amount share the same numeric value (60000), so the test cannot distinguish the amount source (webhook payload vs CREATED log) if they ever diverge.
test/integration/quickbooks/invoicePaid/idempotency.test.ts Correctly tests the claimWebhookEvent short-circuit path; intentionally omits QBInvoiceSync since the claim fires before the invoice lookup, as documented in comments.
test/helpers/seed.ts Adds seedInvoiceCreatedLog and TEST_QB_PAYMENT_ID; well-designed, compile-linked to the mock return value.
test/helpers/mocks.ts Wires createPayment mock return to TEST_QB_PAYMENT_ID constant, eliminating the magic string 'qb-pay-1'.
test/helpers/invoicePaidTestSetup.ts Clean mirror of the paymentSucceeded setup helper; correctly uses clearAllMocks (not restoreAllMocks) to preserve module-level mocks.
test/fixtures/invoicePaid.webhook.ts Typed fixture for invoice.paid webhook payload; correctly references shared seed constants for IDs.
test/integration/quickbooks/invoicePaid/createdLogMissing.test.ts Verifies FAILED log when no CREATED log exists; now checks the exact distinct error message after the production split.
test/integration/quickbooks/invoicePaid/createdLogPending.test.ts Verifies FAILED log when the CREATED log is PENDING; now checks the distinct 'still pending' message after the production split.
test/integration/quickbooks/invoicePaid/qbCreatePaymentFails.test.ts Verifies QB rejection leaves the invoice OPEN and writes a FAILED log; correctly uses optsFactory pattern to inject a fresh rejecting mock per beforeEach.
test/integration/quickbooks/invoicePaid/invoiceNotFound.test.ts Covers the missing QBInvoiceSync branch cleanly.
test/integration/quickbooks/invoicePaid/missingCustomerId.test.ts Covers the null customerId branch; the explicit customerId: null override is redundant with the base fixture default but harmless.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant W as WebhookService
    participant C as claimWebhookEvent
    participant I as InvoiceService
    participant DB as Postgres

    W->>C: claim(copilotId, PAID)
    alt already claimed
        C-->>W: "claimed=false"
        W-->>W: return (idempotency)
    else first delivery
        C-->>W: "claimed=true"
        W->>I: webhookInvoicePaid(payload)
        I->>DB: getInvoiceByNumber()
        alt invoice missing
            I-->>W: throw NOT_FOUND
            W->>DB: updateOrCreateQBSyncLog(FAILED)
        else invoice found
            I->>DB: getOneByCopilotIdAndEventType(PAID)
            alt SUCCESS log exists
                I-->>I: return (idempotency guard)
            end
            I->>DB: check customerId
            alt customerId null
                I-->>W: throw APIError
                W->>DB: updateOrCreateQBSyncLog(FAILED)
            else customerId ok
                I->>DB: getOneByCopilotIdAndEventType(CREATED)
                alt log missing
                    I-->>W: throw Invoice sync log not found
                    W->>DB: updateOrCreateQBSyncLog(FAILED)
                else log PENDING
                    I-->>W: throw Invoice sync log still pending
                    W->>DB: updateOrCreateQBSyncLog(FAILED)
                else log SUCCESS
                    I->>I: createPayment(intuitApi)
                    alt QB rejects
                        I-->>W: throw Error
                        W->>DB: updateOrCreateQBSyncLog(FAILED)
                    else QB accepts
                        I->>DB: updateOrCreateQBSyncLog(SUCCESS)
                        I->>DB: "update QBInvoiceSync status=PAID"
                    end
                end
            end
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant W as WebhookService
    participant C as claimWebhookEvent
    participant I as InvoiceService
    participant DB as Postgres

    W->>C: claim(copilotId, PAID)
    alt already claimed
        C-->>W: "claimed=false"
        W-->>W: return (idempotency)
    else first delivery
        C-->>W: "claimed=true"
        W->>I: webhookInvoicePaid(payload)
        I->>DB: getInvoiceByNumber()
        alt invoice missing
            I-->>W: throw NOT_FOUND
            W->>DB: updateOrCreateQBSyncLog(FAILED)
        else invoice found
            I->>DB: getOneByCopilotIdAndEventType(PAID)
            alt SUCCESS log exists
                I-->>I: return (idempotency guard)
            end
            I->>DB: check customerId
            alt customerId null
                I-->>W: throw APIError
                W->>DB: updateOrCreateQBSyncLog(FAILED)
            else customerId ok
                I->>DB: getOneByCopilotIdAndEventType(CREATED)
                alt log missing
                    I-->>W: throw Invoice sync log not found
                    W->>DB: updateOrCreateQBSyncLog(FAILED)
                else log PENDING
                    I-->>W: throw Invoice sync log still pending
                    W->>DB: updateOrCreateQBSyncLog(FAILED)
                else log SUCCESS
                    I->>I: createPayment(intuitApi)
                    alt QB rejects
                        I-->>W: throw Error
                        W->>DB: updateOrCreateQBSyncLog(FAILED)
                    else QB accepts
                        I->>DB: updateOrCreateQBSyncLog(SUCCESS)
                        I->>DB: "update QBInvoiceSync status=PAID"
                    end
                end
            end
        end
    end
Loading

Reviews (2): Last reviewed commit: "test(OUT-3909): assert taxAmount flows t..." | Re-trigger Greptile

expect(paidLogs).toHaveLength(1)
expect(paidLogs[0]).toMatchObject({
entityType: EntityType.INVOICE,
eventType: EventType.PAID,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Shared error message makes the two "created log" scenarios indistinguishable

Both createdLogMissing and createdLogPending drive different branches of the handler (!invoiceLog vs invoiceLog.status === LogStatus.PENDING), but the production code throws the same error for both: 'Invoice sync log not found or still pending'. As a result, createdLogMissing asserts .toContain('Invoice sync log not found') and createdLogPending asserts .toContain('still pending') — both of which always match that single string. If the handler were ever refactored to collapse the two branches or swap their error text, neither test would catch the regression. Consider asserting 'Invoice sync log not found or still pending' verbatim in both tests, or — better — lobby to split the production error messages so the two failure modes can be distinguished end-to-end.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

eventType: EventType.PAID,
status: LogStatus.FAILED,
})
expect(paidLogs[0].errorMessage).toContain('still pending')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Because both the "log missing" and "log pending" branches throw the same error string ('Invoice sync log not found or still pending'), checking .toContain('still pending') doesn't distinguish this branch from the missing-log case. Asserting on the full message makes the intent explicit and will catch the regression if the production error text is ever changed.

Suggested change
expect(paidLogs[0].errorMessage).toContain('still pending')
expect(paidLogs[0].errorMessage).toContain(
'Invoice sync log not found or still pending',
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +51 to +60
expect(paidLogs[0]).toMatchObject({
portalId: TEST_PORTAL_ID,
entityType: EntityType.INVOICE,
eventType: EventType.PAID,
status: LogStatus.SUCCESS,
copilotId: TEST_COPILOT_INVOICE_ID,
quickbooksId: TEST_QB_PAYMENT_ID,
invoiceNumber: TEST_INVOICE_NUMBER,
amount: '60000.00',
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Add taxAmount to the toMatchObject call so a regression that silently drops or mis-scales the tax column is caught alongside the amount assertion.

Suggested change
expect(paidLogs[0]).toMatchObject({
portalId: TEST_PORTAL_ID,
entityType: EntityType.INVOICE,
eventType: EventType.PAID,
status: LogStatus.SUCCESS,
copilotId: TEST_COPILOT_INVOICE_ID,
quickbooksId: TEST_QB_PAYMENT_ID,
invoiceNumber: TEST_INVOICE_NUMBER,
amount: '60000.00',
})
expect(paidLogs[0]).toMatchObject({
portalId: TEST_PORTAL_ID,
entityType: EntityType.INVOICE,
eventType: EventType.PAID,
status: LogStatus.SUCCESS,
copilotId: TEST_COPILOT_INVOICE_ID,
quickbooksId: TEST_QB_PAYMENT_ID,
invoiceNumber: TEST_INVOICE_NUMBER,
amount: '60000.00',
taxAmount: '0.00',
})

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

The created-log guard threw one shared message for two distinct failure
modes (missing CREATED log vs PENDING CREATED log), so a FAILED PAID log
couldn't tell the resync cron which case it hit. Split into 'Invoice sync
log not found' and 'Invoice sync log still pending' so the two branches
are distinguishable end-to-end, and tighten createdLogPending to assert
the full message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seed a non-zero, non-round tax on the CREATED log and assert it on the
PAID log so a dropped or mis-scaled tax column is caught alongside amount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SandipBajracharya

Copy link
Copy Markdown
Collaborator Author

@greptileai re-review

Condense the multi-line explanatory comments added in this branch to
concise one/two-liners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@priosshrsth priosshrsth left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm.

console.error(
'InvoiceService#webhookInvoicePaid | Invoice sync log still pending',
)
throw Error('Invoice sync log still pending')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good change here. ❤️

Comment thread test/helpers/seed.ts
Comment on lines +182 to +185
const [row] = await db
.insert(QBSyncLog)
.values({ ...baseInvoiceCreatedLog, ...overrides })
.returning()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hate drizzle for these reasons. 😠

@SandipBajracharya
SandipBajracharya merged commit 82f8e1d into master Jun 29, 2026
4 checks passed
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.

2 participants