OUT-3625: suffix DisplayName on QB Vendor/Employee collision - #233
Conversation
QB enforces DisplayName uniqueness across Customer, Vendor, and Employee. Customer creation was failing with error 6240 when a Copilot client's DisplayName collided with an existing QB Vendor/Employee. Pre-check the collision and append " (Customer)" suffix to disambiguate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_customQuery already throws an APIError when the QB response contains a Fault, and returns res.QueryResponse (which has no Fault field). The post-call Fault checks in read methods that wrap customQuery were therefore unreachable. Remove them from the 8 affected methods. Mutation methods (createInvoice, createCustomer, etc.) are unchanged — they call postFetchWithHeaders directly, which does not throw on Fault, so their Fault checks are load-bearing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryAdds a pre-flight Vendor/Employee collision check in Confidence Score: 4/5Safe to merge; the two P2 findings are edge cases that don't affect the common path. The primary fix is correct and well-reasoned. Two P2 findings remain: a src/utils/intuitAPI.ts — review the two P2 comments in Important Files Changed
Sequence DiagramsequenceDiagram
participant CS as CustomerService
participant IA as IntuitAPI
participant QBO as QuickBooks Online
CS->>IA: getCustomerByEmail(email) OR getACustomer(displayName)
IA->>QBO: SELECT Customer WHERE ...
QBO-->>IA: null (not found)
IA-->>CS: null
CS->>IA: getNameCollisionEntity(sanitizedDisplayName)
IA->>QBO: SELECT Id FROM Vendor WHERE DisplayName = ?
QBO-->>IA: Vendor match OR empty
alt Vendor collision found
IA-->>CS: { type: Vendor, id: ... }
CS->>CS: finalDisplayName = sanitizedDisplayName + (Customer)
else No vendor collision
IA->>QBO: SELECT Id FROM Employee WHERE DisplayName = ?
QBO-->>IA: Employee match OR empty
alt Employee collision found
IA-->>CS: { type: Employee, id: ... }
CS->>CS: finalDisplayName = sanitizedDisplayName + (Customer)
else No collision
IA-->>CS: null
CS->>CS: finalDisplayName = sanitizedDisplayName
end
end
CS->>IA: createCustomer({ DisplayName: finalDisplayName, ... })
IA->>QBO: POST /customer
QBO-->>IA: Created customer
IA-->>CS: Customer record
CS->>CS: createQBCustomer mapping (stores original Copilot displayName)
Reviews (1): Last reviewed commit: "refactor: remove dead Fault checks after..." | Re-trigger Greptile |
| const vendorQuery = `SELECT Id FROM Vendor WHERE DisplayName = '${sanitizedDisplayName}' maxresults 1` | ||
| const vendorRes = await this.customQuery(vendorQuery) | ||
|
|
||
| if (vendorRes?.Vendor?.length) { | ||
| return { type: 'Vendor', id: vendorRes.Vendor[0].Id } | ||
| } | ||
|
|
||
| const employeeQuery = `SELECT Id FROM Employee WHERE DisplayName = '${sanitizedDisplayName}' maxresults 1` | ||
| const employeeRes = await this.customQuery(employeeQuery) | ||
|
|
||
| if (employeeRes?.Employee?.length) { | ||
| return { type: 'Employee', id: employeeRes.Employee[0].Id } |
There was a problem hiding this comment.
Suffixed name not re-checked for collision
After finding a collision and computing finalDisplayName = "X (Customer)", the code never verifies that this new name itself is free of Vendor/Employee collisions. If a Vendor (or Employee) named "X (Customer)" already exists, the createCustomer call will still fail with error 6240 — the same error the fix is trying to prevent.
The fix handles the common case well, but a second-pass query for the suffixed name would make it fully resilient. Consider adding a similar check for the (Customer) name before returning, or at minimum documenting the unhandled case.
| async _getNameCollisionEntity( | ||
| displayName: string, | ||
| ): Promise<{ type: 'Vendor' | 'Employee'; id: string } | null> { | ||
| const sanitizedDisplayName = escapeForQBQuery(displayName.trim()) |
There was a problem hiding this comment.
.trim() diverges from the payload name
displayName arrives here already processed by replaceSpecialCharsForQB, which preserves spaces (space is in the allowed-character list). The .trim() call creates a mismatch: the collision query checks the trimmed form while finalDisplayName — and the eventual QB DisplayName payload — retains any leading/trailing spaces from the sanitized name.
In practice display names are unlikely to carry stray spaces, but aligning the trim with the call site (or trimming before replaceSpecialCharsForQB in customer.service.ts) would eliminate the inconsistency.
| const sanitizedDisplayName = escapeForQBQuery(displayName.trim()) | |
| const sanitizedDisplayName = escapeForQBQuery(displayName) |
…re create The previous pre-check only looked at Vendor and Employee, so two Copilot clients with identical display names sharing a QB Vendor would still collide: the first would create "<name> (Customer)" and the second would try the same name and fail with QB error 6200. Replace the collision check with a resolver that builds a candidate sequence (base, "(Customer)", "(Customer) 2", ..., up to 20) and queries Customer, Vendor, and Employee in parallel with DisplayName IN (...), returning the first candidate not in the used set. Case-insensitive match because QBO DisplayName equality is case-insensitive. Not wrapped in wrapWithRetry: the inner customQuery calls already retry on 429, and re-wrapping would amplify rate-limit bursts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
DisplayNamecollides with an existing QB Vendor or Employee (QBO enforcesDisplayNameuniqueness across all three entity types).IntuitAPI.getNameCollisionEntity(displayName)that queries Vendor then Employee; if a collision is found, appends(Customer)suffix to the payloadDisplayNamebefore callingcreateCustomer.customQuerycalls (dead because_customQueryalready throws on Fault and returnsres.QueryResponse, which has noFaultfield).Notes
DisplayNamewith(deleted)when deactivated, freeing the original name._getNameCollisionEntityshort-circuits after finding a Vendor match (skips the Employee query).displayNamecolumn continues to store the Copilot-side original (not the suffixed QB name); the column's semantic is preserved and a comment now documents the intentional divergence.Test plan
Acme Corp, then trigger a Copilot invoice webhook for a client whose display name resolves toAcme Corp→ verify a Customer namedAcme Corp (Customer)is created in QB without error 6240.Beta Inc(QB renames it toBeta Inc (deleted)), trigger webhook for a clientBeta Inc→ verify a Customer namedBeta Incis created (no suffix, since the inactive vendor no longer holds the name).DisplayNameand only 2 QB API calls were made (Vendor query + Employee query, or fewer if short-circuit).findOrCreateCustomerbehaviors unchanged (email-based short-circuit,CompanyNamemismatch clearing).IntuitAPIErrorMessageprefix soisIntuitErrordetection insrc/utils/error.tsstill classifies it correctly.Testing Criteria
handling of duplicate name across Customer, Employee and Vendor
https://www.loom.com/share/14cdacf0e4ed4918ac5b9454ac918eb5
Case when two companies have same name
https://www.loom.com/share/11c319b68ec54fee8b7c8be75ba57581
🤖 Generated with Claude Code