Skip to content

OUT-3868 | OUT-3870: Migrate synced_items to one item per product (drop priceId) - #59

Merged
SandipBajracharya merged 9 commits into
mainfrom
OUT-3868
Jun 17, 2026
Merged

OUT-3868 | OUT-3870: Migrate synced_items to one item per product (drop priceId)#59
SandipBajracharya merged 9 commits into
mainfrom
OUT-3868

Conversation

@SandipBajracharya

@SandipBajracharya SandipBajracharya commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Migrates synced_items from one Xero Item per price to one per product, and updates all downstream code to match. Part of the OUT-3788 simplified-product-mapping epic.

Commits, by scope:

  1. Schema + data migration (09a8b24) — drop the price_id column, replace the (portal_id, product_id, price_id) unique index with (portal_id, tenant_id, product_id), and dedupe existing rows (earliest createdAt wins). (OUT-3868)
  2. Sync/service layer (433356a) — move services to product-level: Mappable drops priceId; getSyncedItemsMapByProductIds, getProductIdToXeroItem, and getCopilotProductAndXeroItem replace their price-keyed equivalents; invoice line items resolve by productId. (OUT-3869)
  3. Mapping UI + API (c633347) — one row per product: ProductMapping becomes { product, item }, getProductMappings iterates products, the action/service operate on { productId, itemId }, and the table drops the per-price/amount lines. (OUT-3870)

Verification

  • pnpm typecheck
  • pnpm lint
  • Pre-push build ✅

Deferred follow-ups (tracked on OUT-3869)

These are webhook/sync-scope and intentionally not in this PR:

  • 🔴 price.createdcreateSyncedItemsForPrices has no "already synced?" guard, so a second price for an already-synced product would create a duplicate Xero Item and hit the new unique constraint (caught → failed_syncs → retried forever). Needs an onConflictDoNothing/existence guard.
  • 🟡 addSyncedItems/deleteSyncedItems use return instead of continue in the missing-itemId guard, aborting the whole batch.

Testing Criteria

https://www.loom.com/share/0d075d8b0ee9431ca9542c300306b169

🤖 Generated with Claude Code

SandipBajracharya and others added 3 commits June 12, 2026 18:05
Move from one Xero Item per price to one per product: drop the price_id
column and replace the (portal_id, product_id, price_id) unique index with
(portal_id, tenant_id, product_id).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the sync/service layer from price-level to product-level now that
synced_items is keyed on (portal_id, tenant_id, product_id):

- Drop priceId from the Mappable type.
- Rename getSyncedItemsMapByPriceIds -> getSyncedItemsMapByProductIds and
  key the map on productId.
- Rename getPriceIdToXeroItem -> getProductIdToXeroItem; resolve invoice
  line items by productId (no longer requires a priceId).
- Rename getCopilotProductAndPrice -> getCopilotProductAndXeroItem, drop the
  now-meaningless per-price fetch and the productPrice sync-log field on
  product map/unmap/update events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Collapse the product-mapping experience to one row per product to match the
product-level synced_items model:

- ProductMapping type -> { product, item } (drop price and item amount).
- getProductMappings iterates products instead of prices and keys synced
  items by productId.
- updateMappedItems and updateSyncedItemsAction operate on { productId, itemId }.
- Mapping table renders one row per product; remove the per-price amount line
  under the product name and the amount under the mapped Xero item.

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

linear-code Bot commented Jun 15, 2026

Copy link
Copy Markdown

OUT-3868

OUT-3670

OUT-3870

@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
xero-integration Ready Ready Preview, Comment Jun 17, 2026 1:23pm

Request Review

@supabase

supabase Bot commented Jun 15, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project pkdwtcdqcefmlgxmcwmc because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR migrates synced_items from a one-row-per-price model to one-row-per-product, touching the DB schema/migration, sync services, and the mapping UI in a consistent three-commit sweep.

  • Schema & migration: drops price_id, replaces the (portal_id, product_id, price_id) unique index with (portal_id, tenant_id, product_id). Deduplication runs manually pre-deployment (confirmed in PR thread).
  • Service layer: getSyncedItemsMapByProductIds replaces the price-keyed variant; getCopilotProductAndXeroItem drops the now-unnecessary price API call; invoice serialization now keys on productId.
  • Mapping UI: ProductMapping becomes { product, item } (no price); the table renders one row per product and the four-case update logic in updateMappedItems (no-op / remove / add / swap) is correct under the new key scheme.

Confidence Score: 4/5

Safe to merge once the manual deduplication DELETE has run against production data; the three deferred issues in SyncedItems.service.ts are acknowledged and tracked but are not introduced by this PR.

The schema, service, and UI changes are mutually consistent and the four-case update logic in updateMappedItems handles all mapping transitions correctly. The known open items — createSyncedItemsForPrices lacking an existence guard, and the return-vs-continue bugs in addSyncedItems/deleteSyncedItems — pre-date or are explicitly deferred from this PR, but they do represent real defects on paths this PR touches.

src/features/items-sync/lib/SyncedItems.service.ts — the three deferred issues all live here and will need resolution before the price.created webhook path is fully safe under the new unique constraint.

Important Files Changed

Filename Overview
src/db/migrations/20260612115624_drop_price_id_from_synced_items.sql Three-step migration: drops old unique index, creates new product-level unique index, drops price_id column. Deduplication step is intentionally absent (to run manually pre-deployment per PR thread).
src/db/schema/syncedItems.schema.ts Removes priceId column and replaces (portalId, productId, priceId) unique index with (portalId, tenantId, productId). Clean, matches the migration.
src/features/items-sync/lib/SyncedItems.service.ts Renames getSyncedItemsMapByPriceIds→ByProductIds, drops priceId from all payloads, removes price-fetching from getCopilotProductAndXeroItem. Three known deferred issues remain: createSyncedItemsForPrices has no existence guard, and addSyncedItems/deleteSyncedItems use return instead of continue in their guard blocks.
src/features/settings/lib/ProductMappings.service.ts getProductMappings now iterates products (not prices), updateMappedItems keys dbMappings by productId. Four-case update logic (no-op, remove, add, swap) correctly handles all transitions.
src/features/invoice-sync/lib/SyncedInvoices.service.ts Renamed getPriceIdToXeroItem→getProductIdToXeroItem and switched lookup key from priceId to productId throughout. Logic is equivalent and correct under the new model.
src/features/invoice-sync/lib/serializers.ts serializeLineItems now looks up xeroItem by productId instead of priceId. Straightforward rename, correct.
src/features/settings/components/ProductMapping/ProductMappingTableRow.tsx Drops per-price amount display, uses product.id as key/ID throughout. renderCurrency is still legitimately used to show prices in the Xero item dropdown list.
src/features/settings/actions/syncedItems.ts Drops priceId from the Mappable payload passed to updateMappedItems. Clean change.
src/features/items-sync/types.ts ProductMapping drops price field and amount from item; Mappable drops priceId. Correctly reflects new product-level model.

Sequence Diagram

sequenceDiagram
    participant UI as ProductMappingTable
    participant Action as updateSyncedItemsAction
    participant PMS as ProductMappingsService
    participant SIS as SyncedItemsService
    participant DB as synced_items (DB)

    UI->>Action: updateSyncedItemsAction(token, productMappings)
    Action->>PMS: "updateMappedItems([{productId, itemId}])"
    PMS->>DB: "SELECT * WHERE portalId + tenantId"
    DB-->>PMS: existing rows (keyed by productId)
    PMS->>PMS: diff → deletedMappings / addedMappings
    PMS->>SIS: deleteSyncedItems(deletedMappings)
    SIS->>DB: DELETE WHERE productId + itemId
    PMS->>SIS: addSyncedItems(addedMappings)
    SIS->>DB: "INSERT {portalId, tenantId, productId, itemId}"
    PMS->>PMS: getProductMappings()
    PMS-->>Action: updated ProductMapping[]
    Action-->>UI: ProductMapping[]
Loading

Reviews (2): Last reviewed commit: "feat(OUT-3868): product-level mapping UI..." | Re-trigger Greptile

@SandipBajracharya SandipBajracharya changed the title OUT-3868: Migrate synced_items to one item per product (drop priceId) OUT-3868 | OUT-3670: Migrate synced_items to one item per product (drop priceId) Jun 15, 2026
@SandipBajracharya SandipBajracharya changed the title OUT-3868 | OUT-3670: Migrate synced_items to one item per product (drop priceId) OUT-3868 | OUT-3870: Migrate synced_items to one item per product (drop priceId) Jun 15, 2026
@SandipBajracharya

Copy link
Copy Markdown
Collaborator Author

@greptileai

@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.

@SandipBajracharya Solid pr. I have added some comments. But they are minor nitpicks only. I think one mapping per product makes more sense than the flow before.

Comment thread src/features/invoice-sync/lib/SyncedInvoices.service.ts
<tr key={item.product.id} className="transition-colors">
{/* Assembly Products Column */}
<td className="py-2 pr-3 pl-4" id={`price-id-${item.price.id}`}>
<td className="py-2 pr-3 pl-4" id={`product-id-${item.product.id}`}>

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.

Any reason for adding id attribute?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Those are just a placeholder ids for easy debugging.

Comment thread src/features/settings/lib/ProductMappings.service.ts Outdated
Comment on lines +49 to +56
item: item
? {
itemID: item.itemID,
code: item.code,
name: item.name,
}
: null,
}

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.

Suggested change
item: item
? {
itemID: item.itemID,
code: item.code,
name: item.name,
}
: null,
}
item: item || null,
}

Would this work?

@SandipBajracharya SandipBajracharya Jun 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It works. We are basically returning the necessary columns to UI. I think I will let it be.

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.

@SandipBajracharya It's about code simplicity too. I think. When we add conditions that is not necessary and handle things that don't need handling, we add additional divergent point to things. Or at least that is my opinion.

- Remove the commented-out "create missing items on invoice" scaffolding from
  getProductIdToXeroItem.
- Simplify the synced-item lookup in getProductMappings to guard on itemId
  directly instead of the empty-string fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SandipBajracharya and others added 5 commits June 17, 2026 19:07
…t.created

Auto-create the Xero item on product.created instead of price.created:

- Replace PriceCreatedEvent/PriceCreatedWebhook schemas with ProductCreated
  equivalents (shared ProductEventSchema with product.updated) and swap the
  discriminated-union member.
- ValidWebhookEvent keeps PriceCreated (marked legacy) for historical
  failed_syncs rows, but it is no longer in the WebhookEvent union or routed.
- handlePriceCreated -> handleProductCreated; logs and returns early when the
  product is already mapped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Replace createSyncedItemsForPrices with createSyncedItemsForProducts: skip
  products that are already mapped, and create the Xero item with no
  salesDetails.unitPrice (invoice lines always supply the price).
- createItems now takes a code -> productId map and uses onConflictDoNothing
  as race safety against the (portalId, tenantId, productId) unique index.
- addSyncedItems/deleteSyncedItems: skip (continue) items missing an itemId
  instead of aborting the whole batch; correct the loop comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y price.created

- failed_syncs_type enum: add product.created (additive migration), keep
  price.created so historical rows stay valid.
- On retry, resolve legacy price.created records as product.created: look up
  the product by the payload's productId, dispatch product.created, and drop
  the legacy row (after the fetch, so transient failures keep it for retry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two concurrent product.created events for the same product could each create a
Xero item; onConflictDoNothing kept only one DB mapping, leaving the other Xero
item orphaned and referenced by a stale sync log. Use returning() to detect
which insert won, delete the losing request's Xero item, and skip its sync log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Delete the legacy row only after a successful product.created dispatch (like
  every other event), so a failure in handleEvent can't lose the row.
- Log when a legacy record is dropped because its product no longer exists or
  its payload has no productId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SandipBajracharya
SandipBajracharya merged commit 1d9834b into main Jun 17, 2026
5 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