OUT-3814: one item per product, driven by product.created - #257
Conversation
- Product mapping lookup is product-keyed (getMappingByProductId, earliest-created canonical); getOne and getAllByProductId filter deletedAt. - Invoice line UnitPrice now comes from the line amount; removed handleItemAmount and the copilotUnitPrice round-trip. - Item creation driven by product.created (webhookProductCreated) instead of price.created; items created at UnitPrice 0 since invoice lines carry their own price. - Lazy invoice-time item creation: no price fetch, one un-suffixed item per product, product-only upsert. - handleProductMap upserts per product; ProductChangedItemReferenceSchema drops priceId/numericPrice. - Removed the unused price.created webhook event and PriceCreatedResponse schema. - Resync (sync.service) reconstructs product.created from the product. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rename the price.created suite to product.created (happyPath, flagOff, idempotency, qbFailure) plus its fixture/helper; drop copilotNotFound; rename multiPrice -> softDeletedMapping. - Add invoice tests: the line amount drives UnitPrice, and lazy item creation makes one un-suffixed item. - Add product/map product-keyed upsert test + postProductMap helper. - Drop priceId from baseProductSync so seeded rows match the new model. - Refresh mock defaults/comments for the product.created flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR migrates QuickBooks item creation from a price-keyed model (one QB item per Assembly product price) to a product-keyed model (one QB item per Assembly product), driven by
Confidence Score: 4/5Safe to merge for portals on the new product-keyed flow; existing portals with legacy multi-price mapping rows may have subtle inconsistencies until OUT-3815 cleans them up. The behavioral changes are well-scoped, all test paths are covered, and the sync service retry path is backward-compatible. The three findings are migration-window quality concerns: the excluded-mapping short-circuit in the idempotency check, the divergent sort orders in getOne vs getMappingByProductId, and the non-atomic CREATED log upsert key. src/app/api/quickbooks/product/product.service.ts — the idempotency check, sort-order divergence, and logSync key are all concentrated here. Important Files Changed
Sequence DiagramsequenceDiagram
participant CW as Copilot Webhook
participant WS as WebhookService
participant PS as ProductService
participant QB as QuickBooks API
participant DB as qb_product_sync
Note over CW,DB: product.created flow (new)
CW->>WS: POST /webhook eventType product.created
WS->>WS: parse ProductCreatedResponseSchema
WS->>PS: webhookProductCreated(resource)
PS->>DB: getAllByProductId(productId) filters deletedAt IS NULL
alt already mapped
PS-->>WS: return idempotent
else not mapped
PS->>QB: getAnItem(name)
alt item exists in QB
QB-->>PS: existing item
else item not found
PS->>QB: createItem UnitPrice 0
QB-->>PS: new item
end
PS->>DB: createQBProduct productId qbItemId
PS->>DB: logSync CREATED SUCCESS
PS-->>WS: done
end
Note over CW,DB: invoice.created lazy item creation
CW->>WS: POST /webhook eventType invoice.created
WS->>PS: ensureProductExistsAndSyncToken(productId)
PS->>DB: getMappingByProductId(productId) asc createdAt
alt no mapping found
PS->>QB: getAnItem or createItem UnitPrice 0
QB-->>PS: item
PS->>DB: updateOrCreateQBProduct
PS->>DB: updateOrCreateQBSyncLog CREATED SUCCESS
end
Note right of WS: UnitPrice = lineItem.amount divided by 100
|
|
Re: P2 — logSync CREATED upsert key not unique in the DB Thanks — verified, with one correction to the mechanism:
async getOne(conditions, orderByDirection: 'asc' | 'desc' = 'desc') {
return this.db.query.QBSyncLog.findFirst({
where: conditions,
orderBy: [orderMap[orderByDirection](QBSyncLog.createdAt)],
})
}So when multiple The residual point is fair: multiple One correction on the mitigation: OUT-3815 is the Treating this as non-blocking given the determinism above and the low impact. If we want DB-enforced dedup, I'll file a separate ticket for a partial unique index on |
priosshrsth
left a comment
There was a problem hiding this comment.
@SandipBajracharya The PR looks good to me. I have added some comments and suggestions. But all of them are opinionated and minor. Let me know what you think about them
QB items are created without a UnitPrice — invoice lines carry their own price. Drop the unused unitPrice option from createItemInQB and its call sites, and remove UnitPrice from QBItemCreatePayloadSchema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… check Replace getAllByProductId(...).length with a single-row getOne existence lookup in webhookProductCreated (same deletedAt-filtered semantics). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Show one row per Assembly product; match mappings by productId (drop priceId predicates in useMapItem / useProductTableSetting / selectItem). - Remove all prices from the table (Assembly product price and QB item price subtitle/dropdown). - getProductsForMapping (was getProductsWithPrices) returns one row per product and no longer fetches Copilot prices; flatten DTO is name-only. - Drop priceId plumbing from MapItemComponent; controller + flatten route renamed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove the "unitPrice is required when isExcluded is false" superRefine from QBProductCreateArraySchema — items are created at price 0 now, so a mapped row legitimately has no unitPrice. - Remove numericPrice from QBItemSchema (changedItemReference.qbItem); its only consumer was handleProductMap, which now stores unitPrice: null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- selectItem and useProductTableSetting always set unitPrice: null — items are price-0 and price isn't tracked in the UI. - Drop the now-dead price/numericPrice fields from QBItemDataType and formatQBItemForListing, and the numericPrice passed into selectItem. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- webhookProductUpdated now sends sparse:true so a name/description update no longer resets the QB item's UnitPrice to 0 (mapped items store null unitPrice now). - handleProductMap calls unsetTransaction() before the initial-save early return, so the DB singleton isn't left on a closed tx. - Drop the now-unused createdAt from the flatten DTO + getProductsForMapping. - Remove stale numericPrice from the productKeyed test fixture; fix stale handleProductMap comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mapped-products filter used the comma operator, so only the qbSyncToken guard survived and the qbItemId guard was discarded — truly unmapped rows (null qbItemId) could slip into the update loop. Use and() to apply both guards. Also drop the stale "updates redis" comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert CopilotAPI._getProducts from positional (name, nextToken, limit) optionals to a single options object and update all callers (product service + backfill/sync cmd scripts). Add defensive optional chaining on products[index] in the product mapping hook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… price columns OUT-3815: add partial unique index uq_qb_product_sync_product_active on (portal_id, product_id) WHERE deleted_at IS NULL. OUT-3819: drop the vestigial price_id, unit_price, copilot_unit_price columns and remove the legacy unitPrice read in webhookProductUpdated. Both schema changes are folded into one migration (20260603102427). The one-time dedup SQL is hand-run against prod before deploy (gitignored snippet). Hardening in product.service.ts: - guard the initial-save batch insert in handleProductMap with onConflictDoNothing scoped to the partial index, so a re-fired/concurrent save no-ops instead of 500-ing. - wrap both db.transaction callbacks in try/finally so unsetTransaction() always restores the db singleton, even on a throw or early return. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The initial-save insert uses onConflictDoNothing, so RETURNING omitted rows skipped on a re-fired/concurrent save and the client could receive []. Always return getAll() instead; drop the now-unused returningFields param. Add a regression test covering a repeated initial save. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What & why
Part of OUT-3787 (simplified product mapping). Previously each Assembly product price created a separate QB Item with auto-suffixed names ("Name (1)", "(2)"), cluttering the QB item list. This maps one Assembly product → one QB Item, with invoice lines carrying their own price.
Scope of this PR: backend + tests only. The mapping-UI changes ship in a follow-up PR (OUT-3816); the DB migration/index ships in OUT-3815.
Changes
getMappingByProductId, earliest-created canonical).getOneandgetAllByProductIdfilterdeletedAt.product.created(webhookProductCreated) instead ofprice.created; theprice.createdevent/handler is removed. Items are created atUnitPrice: 0.UnitPricecomes from the line amount (lineItem.amount / 100); removedhandleItemAmountand thecopilotUnitPriceround-trip. Net: no invoice amount changes (lines already passed line-level price).handleProductMapupserts per product;ProductChangedItemReferenceSchemadropspriceId/numericPrice.sync.service.processProductCreate) reconstructs from the product.Tests
price.createdintegration suite reworked intoproduct.created(happy path, flag-off, idempotency, QB failure, soft-deleted-mapping).UnitPrice; lazy item creation makes one un-suffixed item;product/mapproduct-keyed upsert.tscclean, lint/prettier clean.Testing Criteria
https://www.loom.com/share/c01657c34e08491ea67ff25e42f64756
Notes for review
qb_product_syncprice columns (price_id,unit_price,copilot_unit_price) are intentionally left in the schema; old rows keep their data, new rows leave them null. The partial unique index + one-time cleanup land in OUT-3815.getProductsForMappingprice removal + the settings-table UI.🤖 Generated with Claude Code