Skip to content

OUT-3869: One Xero Item per product — webhook + sync services - #60

Merged
SandipBajracharya merged 5 commits into
OUT-3868from
OUT-3869
Jun 17, 2026
Merged

OUT-3869: One Xero Item per product — webhook + sync services#60
SandipBajracharya merged 5 commits into
OUT-3868from
OUT-3869

Conversation

@SandipBajracharya

@SandipBajracharya SandipBajracharya commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Switches the sync flow from one-Xero-Item-per-price to one-per-product, and moves the auto-sync webhook trigger from price.created to product.created. Part of OUT-3788.

Stacked on OUT-3868. Base is OUT-3868, not main — review/merge that first.

Commits, by concern:

  1. switch product auto-sync from price.created to product.created — webhook event types: ProductCreatedEvent/ProductCreatedWebhook (shared ProductEventSchema with product.updated), added to ValidWebhookEvent + discriminated union; handlePriceCreatedhandleProductCreated (gated by syncProductsAutomatically, skips when already mapped). PriceCreated is kept in ValidWebhookEvent (legacy) but dropped from the WebhookEvent union and routing.
  2. create one Xero item per product, idempotentlycreateSyncedItemsForProducts skips already-mapped products and creates the Xero Item with no salesDetails.unitPrice; createItems takes a code → productId map with .onConflictDoNothing(); addSyncedItems/deleteSyncedItems returncontinue.
  3. add product.created to failed_syncs and resolve legacy price.created — additive enum migration (ALTER TYPE ... ADD VALUE 'product.created', keeps price.created); on retry, legacy price.created records are resolved as product.created (look up product by the payload's productId, dispatch, then drop the legacy row).

Acceptance criteria

  • product.created creates exactly one Xero Item per product (idempotent — no dupes if already mapped)
  • product.updated updates the single item (unchanged)
  • No live price.created handling remains (legacy enum value retained for historical rows only)
  • Invoice line items resolve their Xero Item by productId, carrying the line's unitAmount
  • pnpm typecheck and pnpm lint pass

Testing Criteria

https://www.loom.com/share/a794418fa3e74773acf2a328cd20ce68

Notes

  • The enum migration uses ALTER TYPE ... ADD VALUE — fine on the Supabase Postgres version; it only adds the value (doesn't use it in the same transaction).
  • Reviewed via the fullstack reviewer; the orphaned-legacy-row retry bug it flagged is fixed (delete the legacy row after the fetch, independent of dispatch).

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Jun 15, 2026

Copy link
Copy Markdown

OUT-3869

OUT-3788

@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 15, 2026 12:50pm

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

Switches the Xero sync model from one item per price to one item per product, and moves the auto-sync webhook trigger from price.created to product.created. Includes a DB migration to add the new enum value to failed_syncs_type, orphan-cleanup logic for concurrent creates, and a legacy-row migration path for in-flight price.created failed sync records.

  • SyncedItems.service.ts: createItems now uses onConflictDoNothing().returning() to detect lost DB races and clean up orphaned Xero items; createSyncedItemsForPrices replaced by createSyncedItemsForProducts; returncontinue bug fixed in addSyncedItems/deleteSyncedItems.
  • RetryFailedSyncs.service.ts: Legacy price.created rows are resolved by fetching the product from CopilotAPI and re-dispatching as product.created; the legacy row is deleted only after successful dispatch.
  • types.ts + webhook.service.ts: PriceCreatedWebhookSchema replaced by ProductCreatedWebhookSchema in the discriminated union; PriceCreated retained in the enum for DB/historical row compatibility.

Confidence Score: 5/5

Safe to merge — the concurrent-create race is now handled end-to-end, the legacy-row migration path is correct, and the core idempotency guarantees hold.

All three previously-raised concerns were addressed: orphaned Xero items from concurrent creates are cleaned up via onConflictDoNothing().returning() plus a best-effort delete; the product-not-found case now emits a warning log; and the legacy price.created row is deleted only after handleEvent returns successfully. No new correctness issues were found.

No files require special attention. The most complex logic in SyncedItems.service.ts and RetryFailedSyncs.service.ts is well-guarded.

Important Files Changed

Filename Overview
src/features/items-sync/lib/SyncedItems.service.ts Core logic change: createSyncedItemsForPrices replaced by createSyncedItemsForProducts; createItems now uses onConflictDoNothing().returning() to detect lost DB races and clean up orphaned Xero items; return replaced by continue bug fixed in addSyncedItems/deleteSyncedItems.
src/features/failed-syncs/lib/RetryFailedSyncs.service.ts Adds a legacy-row migration path: resolves price.created records by fetching the product from CopilotAPI and re-dispatching as product.created; row deleted only after successful dispatch.
src/features/webhook/lib/webhook.service.ts Swaps handlePriceCreated for handleProductCreated in the event handler map; new handler correctly gates on syncProductsAutomatically and handles the already-mapped no-op case.
src/features/invoice-sync/types.ts Introduces ProductEventSchema shared by both ProductCreated and ProductUpdated; replaces PriceCreatedWebhookSchema in the discriminated union with ProductCreatedWebhookSchema; PriceCreated retained in enum for DB compatibility.
src/db/migrations/20260615092651_add_product_created_to_failed_syncs_type.sql Additive ALTER TYPE ADD VALUE migration; safe for Postgres.
src/db/schema/failedSyncs.schema.ts Adds ProductCreated to the Drizzle enum, keeping PriceCreated with a legacy comment.

Sequence Diagram

sequenceDiagram
    participant Copilot
    participant WebhookService
    participant SyncedItemsService
    participant XeroAPI
    participant DB

    note over Copilot,DB: Live product.created webhook
    Copilot->>WebhookService: handleEvent(product.created)
    WebhookService->>WebhookService: checkAutomaticProductSyncEnabled()
    WebhookService->>SyncedItemsService: createSyncedItemsForProducts([product])
    SyncedItemsService->>DB: getSyncedItemsMapByProductIds([productId])
    DB-->>SyncedItemsService: existingMappings
    alt product already mapped
        SyncedItemsService-->>WebhookService: [] (skip)
    else product not yet mapped
        SyncedItemsService->>XeroAPI: "createItems([{code, name, description}])"
        XeroAPI-->>SyncedItemsService: newlyCreatedItems
        SyncedItemsService->>DB: INSERT INTO synced_items ON CONFLICT DO NOTHING RETURNING
        alt won the insert race
            DB-->>SyncedItemsService: inserted row
            SyncedItemsService-->>WebhookService: [item]
        else lost the insert race
            DB-->>SyncedItemsService: (empty)
            SyncedItemsService->>XeroAPI: deleteItem(orphanedItemId)
            SyncedItemsService-->>WebhookService: [] (orphan cleaned up)
        end
    end

    note over Copilot,DB: Legacy price.created retry
    DB-->>WebhookService: "legacyRow {payload: {productId}}"
    WebhookService->>Copilot: getProductsMapById([productId])
    Copilot-->>WebhookService: product
    WebhookService->>WebhookService: handleEvent(product.created)
    WebhookService->>DB: "DELETE FROM failed_syncs WHERE id=legacyRow.id"
Loading

Reviews (3): Last reviewed commit: "fix(OUT-3869): harden legacy price.creat..." | Re-trigger Greptile

Comment thread src/features/items-sync/lib/SyncedItems.service.ts
Comment thread src/features/failed-syncs/lib/RetryFailedSyncs.service.ts Outdated
SandipBajracharya and others added 4 commits June 15, 2026 17:44
…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>
@SandipBajracharya

Copy link
Copy Markdown
Collaborator Author

@greptileai

Comment thread src/features/failed-syncs/lib/RetryFailedSyncs.service.ts Outdated
- 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

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 lgtm. I have added few comments more related to code style.


// Resolve legacy price.created records as product.created via the payload's productId
if (failedSync.type === ValidWebhookEvent.PriceCreated) {
const { productId } = (failedSync.payload ?? {}) as { productId?: string }

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
const { productId } = (failedSync.payload ?? {}) as { productId?: string }
const productId = failedSync.payload?.productId;

II think this should simplfy if failedSync.payload is well typed. Otherwise your current approach is fine.

'WebhookService#handleProductCreated :: Product already mapped, nothing to do',
data.id,
)
return

@priosshrsth priosshrsth Jun 16, 2026

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 don't like how we are relying on empty array => already mapped logic. But I think we are using upsert to create or update. So I am ok with this for now. But we should try to avoid using logic like this if possible.

// One Xero Item per product: skip products that are already mapped
const existingMappings = await this.getSyncedItemsMapByProductIds(products.map((p) => p.id))

for (const product of products) {

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.

Not in the scope of this PR. But if there is too much processing to be done, We should extract these to a function. Fine for now.

@SandipBajracharya
SandipBajracharya merged commit 1681298 into OUT-3868 Jun 17, 2026
6 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