diff --git a/.gitignore b/.gitignore index be7263d6..871187c7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ next-env.d.ts # local decision notes (not published) /docs +/supabase/snippets \ No newline at end of file diff --git a/src/app/api/quickbooks/product/flatten/route.ts b/src/app/api/quickbooks/product/flatten/route.ts index d5612e17..dc8ad5a6 100644 --- a/src/app/api/quickbooks/product/flatten/route.ts +++ b/src/app/api/quickbooks/product/flatten/route.ts @@ -1,6 +1,6 @@ import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -import { getProductsWithPrices } from '@/app/api/quickbooks/product/product.controller' +import { getProductsForMapping } from '@/app/api/quickbooks/product/product.controller' export const maxDuration = 300 // 5 minutes -export const GET = withErrorHandler(getProductsWithPrices) +export const GET = withErrorHandler(getProductsForMapping) diff --git a/src/app/api/quickbooks/product/product.controller.ts b/src/app/api/quickbooks/product/product.controller.ts index 41b42a4b..cd29c935 100644 --- a/src/app/api/quickbooks/product/product.controller.ts +++ b/src/app/api/quickbooks/product/product.controller.ts @@ -5,10 +5,10 @@ import { ProductService } from '@/app/api/quickbooks/product/product.service' import { ProductMappingSchema } from '@/db/schema/qbProductSync' import { NextRequest, NextResponse } from 'next/server' -export async function getProductsWithPrices(req: NextRequest) { +export async function getProductsForMapping(req: NextRequest) { const user = await authenticate(req) const productService = new ProductService(user) - const products = await productService.getProductsWithPrices() + const products = await productService.getProductsForMapping() return NextResponse.json(products) } diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index baf71fd4..7f4c0854 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -12,7 +12,7 @@ import { ProductChangedItemReferenceType, ProductMappingSchemaType, } from '@/db/schema/qbProductSync' -import { PriceResponse, WhereClause } from '@/type/common' +import { WhereClause } from '@/type/common' import { ProductFlattenArrayResponseType } from '@/type/dto/api.dto' import { QBItemFullUpdatePayloadType } from '@/type/dto/intuitAPI.dto' import { @@ -206,12 +206,9 @@ export class ProductService extends BaseService { /** * On intial save, save all flatten products. If mapped, we include and if not, those are excluded - * On every save after that, we update the record on the basis of productId and priceId + * On every save after that, we update the record on the basis of productId */ - async handleProductMap( - body: ProductMappingSchemaType, - returningFields?: (keyof typeof QBProductSync)[], - ) { + async handleProductMap(body: ProductMappingSchemaType) { const { mappingItems, changedItemReference } = body const settingService = new SettingService(this.user) const setting = await settingService.getOneByPortalId([ @@ -220,50 +217,51 @@ export class ProductService extends BaseService { return await this.db.transaction(async (tx) => { this.setTransaction(tx) - - if (!setting?.initialProductSettingMap) { - const formattedPayload = mappingItems.map((item) => { - return { - ...item, - portalId: this.user.workspaceId, - } - }) - const query = this.db.insert(QBProductSync).values(formattedPayload) - const products = returningFields?.length - ? await query.returning( - buildReturningFields(QBProductSync, returningFields), - ) - : await query.returning() - return products - } - - if (changedItemReference.length > 0) { - await Promise.all( - changedItemReference?.map(async (item) => { - const payload = { + try { + if (!setting?.initialProductSettingMap) { + const formattedPayload = mappingItems.map((item) => { + return { + ...item, portalId: this.user.workspaceId, - productId: item.id, - name: item.isExcluded ? null : item.qbItem?.name, - description: item.isExcluded ? null : item.description, - qbItemId: item.isExcluded ? null : item.qbItem?.id, - qbSyncToken: item.isExcluded ? null : item.qbItem?.syncToken, - copilotName: item.name, - unitPrice: item.isExcluded - ? null - : item.qbItem?.numericPrice.toString(), - isExcluded: item.isExcluded, } - const conditions = and( - eq(QBProductSync.portalId, this.user.workspaceId), - eq(QBProductSync.productId, item.id), - ) as WhereClause - await this.updateOrCreateQBProduct(payload, conditions) - }), - ) - } + }) + // Skip products already saved so a repeated save doesn't error. + await this.db + .insert(QBProductSync) + .values(formattedPayload) + .onConflictDoNothing({ + target: [QBProductSync.portalId, QBProductSync.productId], + where: isNull(QBProductSync.deletedAt), + }) + return await this.getAll() + } - this.unsetTransaction() - return await this.getAll() + if (changedItemReference.length > 0) { + await Promise.all( + changedItemReference?.map(async (item) => { + const payload = { + portalId: this.user.workspaceId, + productId: item.id, + name: item.isExcluded ? null : item.qbItem?.name, + description: item.isExcluded ? null : item.description, + qbItemId: item.isExcluded ? null : item.qbItem?.id, + qbSyncToken: item.isExcluded ? null : item.qbItem?.syncToken, + copilotName: item.name, + isExcluded: item.isExcluded, + } + const conditions = and( + eq(QBProductSync.portalId, this.user.workspaceId), + eq(QBProductSync.productId, item.id), + ) as WhereClause + await this.updateOrCreateQBProduct(payload, conditions) + }), + ) + } + + return await this.getAll() + } finally { + this.unsetTransaction() + } }) } @@ -323,78 +321,26 @@ export class ProductService extends BaseService { return await intuitApi.createItem(qbItemPayload) } - async getProductsWithPrices(): Promise { + /** + * Returns one row per Assembly product for the mapping table. Prices are no + * longer fetched — one product maps to one QB item, and invoice lines carry + * their own UnitPrice, so the table is product-to-item only. + */ + async getProductsForMapping(): Promise { const copilot = new CopilotAPI(this.user.token) - - const [products, pricesByProduct] = await Promise.all([ - copilot.getProducts(undefined, undefined, MAX_PRODUCT_LIST_LIMIT), - this.fetchAllPricesGroupedByProduct(copilot), - ]) - - const flattened = (products?.data ?? []).flatMap((product) => { - const prices = pricesByProduct.get(product.id) ?? [] - const productDescription = convert(product.description) - return prices - .map((price) => ({ - ...product, - description: productDescription, - priceId: price.id, - amount: price.amount, - type: price.type, - interval: price.interval, - intervalCount: price.intervalCount, - currency: price.currency, - })) - .sort((a, b) => a.amount - b.amount) // sort by amount in asc order + const products = await copilot.getProducts({ + limit: MAX_PRODUCT_LIST_LIMIT, }) - return { products: flattened } - } - - /** - * Walks every page of the workspace's /prices endpoint and groups by - * productId. Replaces the prior bottleneck-throttled N+1 per-product fetch - * with ceil(totalPrices / MAX_PRODUCT_LIST_LIMIT) sequential calls, which is - * dramatically faster for the single-page workload getProductsWithPrices - * actually serves. If product pagination is ever reintroduced, revisit: - * caller would repeat this full walk per page with no cross-call cache. - */ - private async fetchAllPricesGroupedByProduct( - copilot: CopilotAPI, - ): Promise> { - const grouped = new Map() - let nextToken: string | undefined - do { - const page = await copilot.getPrices( - undefined, - nextToken, - MAX_PRODUCT_LIST_LIMIT.toString(), - ) - if (!page) { - // Transient SDK failure: bail rather than silently dropping every - // product on the page from the flattened response. - console.warn( - 'fetchAllPricesGroupedByProduct | getPrices returned undefined; aborting pagination', - ) - break - } - for (const price of page.data ?? []) { - const list = grouped.get(price.productId) - if (list) { - list.push(price) - } else { - grouped.set(price.productId, [price]) - } - } - nextToken = page.nextToken - } while (nextToken) + const formatted = (products?.data ?? []).map((product) => ({ + id: product.id, + name: product.name, + description: convert(product.description), + })) - return grouped + return { products: formatted } } - /** - * Updates the cached product list in redis - */ async webhookProductUpdated( resource: ProductUpdatedResponseType, qbTokenInfo: IntuitAPITokensType, @@ -405,22 +351,15 @@ export class ProductService extends BaseService { }) // 01. get all the mapped product ids with qb id - const mappedConditions = - (not(isNull(QBProductSync.qbItemId)), - not(isNull(QBProductSync.qbSyncToken))) + const mappedConditions = and( + not(isNull(QBProductSync.qbItemId)), + not(isNull(QBProductSync.qbSyncToken)), + ) const mappedProducts = await this.getAllByProductId( productResource.id, mappedConditions, - [ - 'id', - 'qbItemId', - 'qbSyncToken', - 'name', - 'description', - 'unitPrice', - 'copilotName', - ], + ['id', 'qbItemId', 'qbSyncToken', 'name', 'description', 'copilotName'], ) if (!mappedProducts || !mappedProducts.length) { @@ -495,14 +434,12 @@ export class ProductService extends BaseService { qbTokenInfo.incomeAccountRef, ) - const fullUpdatePayload: QBItemFullUpdatePayloadType = { + const updatePayload: QBItemFullUpdatePayloadType = { Id: qbItemId, SyncToken: syncToken, Name: qbItemName, + sparse: true, ...(productDescription && { Description: productDescription }), - ...(product.unitPrice - ? { UnitPrice: parseFloat(product.unitPrice) / 100 } - : {}), IncomeAccountRef: { value: z.string().parse(incomeAccountRef), }, @@ -510,7 +447,7 @@ export class ProductService extends BaseService { Type: QBItemType.SERVICE, } - const itemRes = await intuitApi.itemFullUpdate(fullUpdatePayload) + const itemRes = await intuitApi.itemFullUpdate(updatePayload) // update the product map in db const mapUpdatePayload = { @@ -553,72 +490,74 @@ export class ProductService extends BaseService { await this.db.transaction(async (tx) => { this.setTransaction(tx) + try { + const mappedProduct = await this.getOne( + // 01. if this product is already mapped to a QB item, do nothing. + and( + eq(QBProductSync.portalId, this.user.workspaceId), + eq(QBProductSync.productId, productResource.id), + ) as WhereClause, + ['id'], + ) - const mappedProduct = await this.getOne( - // 01. if this product is already mapped to a QB item, do nothing. - and( - eq(QBProductSync.portalId, this.user.workspaceId), - eq(QBProductSync.productId, productResource.id), - ) as WhereClause, - ['id'], - ) - - addSyncBreadcrumb('Product mapping check', { - alreadyMapped: !!mappedProduct, - }) - if (mappedProduct) { - console.info('Product already mapped to a QB item; skipping') - return - } + addSyncBreadcrumb('Product mapping check', { + alreadyMapped: !!mappedProduct, + }) + if (mappedProduct) { + console.info('Product already mapped to a QB item; skipping') + return + } - const qbItemName = truncateForQB( - replaceSpecialCharsForQB(productResource.name), - ) - const productDescription = convert(productResource.description) + const qbItemName = truncateForQB( + replaceSpecialCharsForQB(productResource.name), + ) + const productDescription = convert(productResource.description) + + // check if item with name exists in QBO + let qbItem = await intuitApi.getAnItem(qbItemName, undefined, true) + + if (!qbItem) { + const tokenService = new TokenService(this.user) + const incomeAccountRef = + await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.Income, + qbTokenInfo.intuitRealmId, + intuitApi, + qbTokenInfo.incomeAccountRef, + ) + // create item in QB. No price at product.created time — invoice lines + // carry their own UnitPrice. + qbItem = await this.createItemInQB( + { + productName: z.string().parse(qbItemName), + incomeAccRefVal: z.string().parse(incomeAccountRef), + productDescription, + }, + intuitApi, + ) + } - // check if item with name exists in QBO - let qbItem = await intuitApi.getAnItem(qbItemName, undefined, true) + // map product to the QB item + await this.createQBProduct({ + portalId: this.user.workspaceId, + productId: productResource.id, + qbItemId: qbItem.Id, + qbSyncToken: qbItem.SyncToken, + name: qbItemName, + copilotName: productResource.name, + description: productDescription, + }) - if (!qbItem) { - const tokenService = new TokenService(this.user) - const incomeAccountRef = await tokenService.checkAndUpdateAccountStatus( - AccountTypeObj.Income, - qbTokenInfo.intuitRealmId, - intuitApi, - qbTokenInfo.incomeAccountRef, - ) - // create item in QB. No price at product.created time — invoice lines - // carry their own UnitPrice. - qbItem = await this.createItemInQB( - { - productName: z.string().parse(qbItemName), - incomeAccRefVal: z.string().parse(incomeAccountRef), - productDescription, - }, - intuitApi, + console.info( + 'WebhookService#webhookProductCreated | Product created in QB', ) + await this.logSync(productResource.id, qbItem.Id, EventType.CREATED, { + productName: productResource.name, + qbItemName: qbItem.Name, + }) + } finally { + this.unsetTransaction() } - - // map product to the QB item - await this.createQBProduct({ - portalId: this.user.workspaceId, - productId: productResource.id, - qbItemId: qbItem.Id, - qbSyncToken: qbItem.SyncToken, - name: qbItemName, - copilotName: productResource.name, - description: productDescription, - }) - - console.info( - 'WebhookService#webhookProductCreated | Product created in QB', - ) - await this.logSync(productResource.id, qbItem.Id, EventType.CREATED, { - productName: productResource.name, - qbItemName: qbItem.Name, - }) - - this.unsetTransaction() }) } @@ -669,20 +608,16 @@ export class ProductService extends BaseService { productName: string | null }, ) { - const conditions: SQL[] = [eq(QBSyncLog.portalId, this.user.workspaceId)] + const conditions: SQL[] = [ + eq(QBSyncLog.portalId, this.user.workspaceId), + eq(QBSyncLog.copilotId, copilotId), // one item per product, so the update log is keyed on the product + eq(QBSyncLog.eventType, eventType), + ] if (eventType === EventType.UPDATED) { conditions.push( - eq(QBSyncLog.copilotId, copilotId), // one item per product, so the update log is keyed on the product - eq(QBSyncLog.eventType, eventType), - eq(QBSyncLog.status, LogStatus.FAILED), eq(QBSyncLog.quickbooksId, quickbooksId), - ) - } else { - // product.created: one item per product, so key the upsert on the product - conditions.push( - eq(QBSyncLog.copilotId, copilotId), - eq(QBSyncLog.eventType, eventType), + eq(QBSyncLog.status, LogStatus.FAILED), ) } @@ -761,7 +696,7 @@ export class ProductService extends BaseService { async unmapProducts(qbItemId: string): Promise { await this.db .update(QBProductSync) - .set({ qbItemId: null, qbSyncToken: null, name: null, unitPrice: null }) + .set({ qbItemId: null, qbSyncToken: null, name: null }) .where( and( eq(QBProductSync.qbItemId, qbItemId), diff --git a/src/cmd/backfillProductInfo/backfillProductInfo.service.ts b/src/cmd/backfillProductInfo/backfillProductInfo.service.ts index e8d1a29f..bda0a91c 100644 --- a/src/cmd/backfillProductInfo/backfillProductInfo.service.ts +++ b/src/cmd/backfillProductInfo/backfillProductInfo.service.ts @@ -40,11 +40,7 @@ export class BackfillProductInfoService extends BaseService { // 2. get all products from assembly const copilotApi = new CopilotAPI(this.user.token) const assemblyProducts = ( - await copilotApi.getProducts( - undefined, - undefined, - MAX_PRODUCT_LIST_LIMIT, - ) + await copilotApi.getProducts({ limit: MAX_PRODUCT_LIST_LIMIT }) )?.data if (!assemblyProducts) { diff --git a/src/cmd/syncMissedProducts/syncMissedProducts.service.ts b/src/cmd/syncMissedProducts/syncMissedProducts.service.ts index baa81dfa..e8d0e56a 100644 --- a/src/cmd/syncMissedProducts/syncMissedProducts.service.ts +++ b/src/cmd/syncMissedProducts/syncMissedProducts.service.ts @@ -27,11 +27,9 @@ export class SyncMissedProductsService extends BaseService { // 1. Get all the products for the portal const copilotApi = new CopilotAPI(this.user.token) - const allProducts = await copilotApi.getProducts( - undefined, - undefined, - MAX_PRODUCT_LIST_LIMIT, - ) + const allProducts = await copilotApi.getProducts({ + limit: MAX_PRODUCT_LIST_LIMIT, + }) const filteredProducts = allProducts?.data?.filter( (product) => diff --git a/src/components/dashboard/settings/sections/product/ProductMappingTable.tsx b/src/components/dashboard/settings/sections/product/ProductMappingTable.tsx index af9bb72e..646b1a98 100644 --- a/src/components/dashboard/settings/sections/product/ProductMappingTable.tsx +++ b/src/components/dashboard/settings/sections/product/ProductMappingTable.tsx @@ -15,38 +15,20 @@ import { CalloutVariant } from '@/components/type/callout' const MapItemComponent = ({ mappingItems, productId, - priceId, qbItems, }: { mappingItems: ProductMappingItemType[] | undefined productId: string - priceId: string qbItems: QBItemDataType[] | undefined }) => { - const { currentlyMapped } = useMapItem( - mappingItems, - productId, - priceId, - qbItems, - ) + const { currentlyMapped } = useMapItem(mappingItems, productId, qbItems) return ( <> {currentlyMapped ? ( -
+
{currentlyMapped?.name}
-
- {currentlyMapped.unitPrice && - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format( - currentlyMapped.unitPrice - ? parseFloat(currentlyMapped.unitPrice) / 100 - : 0, - )} -
) : (
@@ -146,9 +128,6 @@ export default function ProductMappingTable({ )}
-
- {product.price} -
{/* Arrow Column */} @@ -171,19 +150,15 @@ export default function ProductMappingTable({
{selectedItems[index] && Object.keys(selectedItems[index]).length > 0 ? ( -
+
{selectedItems[index].name}
-
- {selectedItems[index].price} -
) : ( )} @@ -235,9 +210,7 @@ export default function ProductMappingTable({ id: item.id, name: item.name, description: item.description, - price: item.price, syncToken: item.syncToken, - numericPrice: item.numericPrice, }, products, ) @@ -247,9 +220,6 @@ export default function ProductMappingTable({ {item.name} - - {item.price} - ), )} diff --git a/src/db/migrations/20260603102427_collapse_qb_product_sync_one_row_drop_price_columns.sql b/src/db/migrations/20260603102427_collapse_qb_product_sync_one_row_drop_price_columns.sql new file mode 100644 index 00000000..b4a4adef --- /dev/null +++ b/src/db/migrations/20260603102427_collapse_qb_product_sync_one_row_drop_price_columns.sql @@ -0,0 +1,4 @@ +CREATE UNIQUE INDEX "uq_qb_product_sync_product_active" ON "qb_product_sync" USING btree ("portal_id","product_id") WHERE "qb_product_sync"."deleted_at" is null;--> statement-breakpoint +ALTER TABLE "qb_product_sync" DROP COLUMN "price_id";--> statement-breakpoint +ALTER TABLE "qb_product_sync" DROP COLUMN "unit_price";--> statement-breakpoint +ALTER TABLE "qb_product_sync" DROP COLUMN "copilot_unit_price"; \ No newline at end of file diff --git a/src/db/migrations/meta/20260603102427_snapshot.json b/src/db/migrations/meta/20260603102427_snapshot.json new file mode 100644 index 00000000..5853a973 --- /dev/null +++ b/src/db/migrations/meta/20260603102427_snapshot.json @@ -0,0 +1,1131 @@ +{ + "id": "9dfe5213-8442-4637-946c-257a8f151d40", + "prevId": "60eb52cf-4582-44bd-b5da-2b4e7a5c5403", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_doc_number": { + "name": "qb_doc_number", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_product_sync_product_active": { + "name": "uq_qb_product_sync_product_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_product_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "should_retry": { + "name": "should_retry", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 52493d68..82075dc5 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1779277043255, "tag": "20260520113723_add_should_retry_to_qb_sync_logs", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1780482267187, + "tag": "20260603102427_collapse_qb_product_sync_one_row_drop_price_columns", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/qbProductSync.ts b/src/db/schema/qbProductSync.ts index da97786c..9667d304 100644 --- a/src/db/schema/qbProductSync.ts +++ b/src/db/schema/qbProductSync.ts @@ -1,24 +1,33 @@ import { timestamps } from '@/db/helper/column.helper' +import { isNull } from 'drizzle-orm' import { pgTable as table } from 'drizzle-orm/pg-core' import * as t from 'drizzle-orm/pg-core' import { createInsertSchema, createSelectSchema } from 'drizzle-zod' import { z } from 'zod' -export const QBProductSync = table('qb_product_sync', { - id: t.uuid().defaultRandom().primaryKey(), - portalId: t.varchar('portal_id', { length: 255 }).notNull(), - productId: t.uuid('product_id'), - priceId: t.varchar('price_id'), - name: t.varchar(), - description: t.text(), - copilotName: t.varchar('copilot_name'), - unitPrice: t.decimal('unit_price'), - copilotUnitPrice: t.decimal('copilot_unit_price'), - qbItemId: t.varchar('qb_item_id'), - qbSyncToken: t.varchar('qb_sync_token', { length: 100 }), - isExcluded: t.boolean('is_excluded').default(false), - ...timestamps, -}) +export const QBProductSync = table( + 'qb_product_sync', + { + id: t.uuid().defaultRandom().primaryKey(), + portalId: t.varchar('portal_id', { length: 255 }).notNull(), + productId: t.uuid('product_id'), + name: t.varchar(), + description: t.text(), + copilotName: t.varchar('copilot_name'), + qbItemId: t.varchar('qb_item_id'), + qbSyncToken: t.varchar('qb_sync_token', { length: 100 }), + isExcluded: t.boolean('is_excluded').default(false), + ...timestamps, + }, + (table) => [ + // One canonical live row per product (one-item-per-product, OUT-3787). + // Partial so soft-deleted rows don't block re-mapping the same product. + t + .uniqueIndex('uq_qb_product_sync_product_active') + .on(table.portalId, table.productId) + .where(isNull(table.deletedAt)), + ], +) export const QBProductCreateSchema = createInsertSchema(QBProductSync) export type QBProductCreateSchemaType = z.infer @@ -48,13 +57,6 @@ export const QBProductCreateArraySchema = z.array( message: 'qbSyncToken is required when isExcluded is false', }) } - if (!val.unitPrice) { - ctx.addIssue({ - path: ['unitPrice'], - code: z.ZodIssueCode.custom, - message: 'unitPrice is required when isExcluded is false', - }) - } } }), ) @@ -81,7 +83,6 @@ const QBItemSchema = z.object({ id: z.string(), name: z.string(), syncToken: z.string(), - numericPrice: z.number(), description: z.string(), }) diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index 68acd895..4505321b 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -6,7 +6,6 @@ import { ProductFlattenArrayResponseType, ProductFlattenResponseType, } from '@/type/dto/api.dto' -import { getTimeInterval } from '@/utils/common' import { QBO_ITEM_NAME_MAX_LENGTH } from '@/utils/string' import { ProductMappingItemType } from '@/db/schema/qbProductSync' import { patchFetcher, postFetcher } from '@/helper/fetch.helper' @@ -31,9 +30,6 @@ export type QuickbooksItemType = { export type ProductDataType = { id: string name: string - price: string - priceId: string - numericPrice: number description?: string isNameTooLong: boolean } @@ -41,10 +37,8 @@ export type ProductDataType = { export type QBItemDataType = { name: string description: string - price: string syncToken: string id: string - numericPrice: number } export const useProductMappingSettings = () => { @@ -194,11 +188,7 @@ export const useProductMappingSettings = () => { [index]: '', })) const fileteredChangedItem = changedItemReference.filter( - (item) => - !( - item.id === products[index].id && - item.priceId === products[index].priceId - ), + (item) => item.id !== products[index]?.id, ) const newVal = [ ...fileteredChangedItem, @@ -210,18 +200,12 @@ export const useProductMappingSettings = () => { // update the mapped array const mappedArray = mappingItems.map((mapItem) => { - if ( - mapItem.productId === products[index].id && - mapItem.priceId === products[index].priceId - ) { + if (mapItem.productId === products[index]?.id) { return { ...mapItem, name: item.name || null, description: item.description || '', - priceId: products[index].priceId, productId: products[index].id, - unitPrice: item.numericPrice?.toFixed() || null, - copilotUnitPrice: products[index].numericPrice.toFixed(), copilotName: products[index].name, qbItemId: item.id || null, qbSyncToken: item.syncToken || null, @@ -279,22 +263,12 @@ function formatProductDataForListing( data: ProductFlattenArrayResponseType, ): ProductDataType[] | undefined { return data?.products?.length - ? data.products.map((product) => { - const price = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(product.amount / 100) - const newPrice = `${price} ${product?.interval && product?.intervalCount ? `/ ${getTimeInterval(product.interval, product.intervalCount)}` : ''}` - return { - id: product.id, - name: product.name, - description: product.description || '', - price: newPrice, - numericPrice: product.amount, - priceId: product.priceId, - isNameTooLong: product.name.length > QBO_ITEM_NAME_MAX_LENGTH, - } - }) + ? data.products.map((product) => ({ + id: product.id, + name: product.name, + description: product.description || '', + isNameTooLong: product.name.length > QBO_ITEM_NAME_MAX_LENGTH, + })) : undefined } @@ -303,16 +277,10 @@ function formatQBItemForListing( ): QBItemDataType[] | undefined { return data?.length ? data.map((product) => { - const price = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(product.UnitPrice) return { id: product.Id, name: product.Name, description: product?.Description || '', - price: price, - numericPrice: product.UnitPrice * 100, syncToken: product.SyncToken, } }) @@ -325,9 +293,7 @@ export const useProductTableSetting = ( const emptyMappedItem = { name: null, description: '', - priceId: null, productId: null, - unitPrice: null, qbItemId: null, qbSyncToken: null, isExcluded: true, @@ -356,9 +322,7 @@ export const useProductTableSetting = ( (product: ProductFlattenResponseType) => { return { ...emptyMappedItem, - priceId: product.priceId, productId: product.id, - copilotUnitPrice: product.amount.toFixed(), copilotName: product.name, } }, @@ -369,31 +333,23 @@ export const useProductTableSetting = ( const mappedItem = mappedItems.find( // search for the already mapped product from the mapped list (item: ProductMappingItemType) => - item.productId === product.id && - item.priceId === product.priceId && - item.qbItemId, + item.productId === product.id && item.qbItemId, ) if (mappedItem) { // if found, return with the mapped product in mapping item return { name: mappedItem.name, description: mappedItem.description, - priceId: product.priceId, productId: product.id, - unitPrice: - mappedItem.unitPrice && mappedItem.unitPrice.toString(), qbItemId: mappedItem.qbItemId, qbSyncToken: mappedItem.qbSyncToken, - copilotUnitPrice: product.amount.toFixed(), copilotName: product.name, isExcluded: false, } } return { ...emptyMappedItem, - priceId: product.priceId, productId: product.id, - copilotUnitPrice: product.amount.toFixed(), copilotName: product.name, } }, @@ -440,34 +396,24 @@ export const useProductTableSetting = ( export const useMapItem = ( mappingItems: ProductMappingItemType[] | undefined, productId: string, - priceId: string, qbItems: QBItemDataType[] | undefined, ) => { const [currentlyMapped, setCurrentlyMapped] = useState< - ProductMappingItemType | undefined + { name: string } | undefined >() const checkIfMappedItemExists = () => { const currentMapItem = mappingItems?.find((item) => { - return ( - item.productId === productId && - item.priceId === priceId && - item.qbItemId - ) + return item.productId === productId && item.qbItemId }) const currentQbItem = qbItems?.find((item) => { return item.id === currentMapItem?.qbItemId }) - let itemToReturn: { name: string; unitPrice: string } | undefined + let itemToReturn: { name: string } | undefined const itemName = currentQbItem?.name || currentMapItem?.name - const itemUnitPrice = - currentQbItem?.numericPrice.toFixed(2) || currentMapItem?.unitPrice - if (itemName && itemUnitPrice) { - itemToReturn = { - name: itemName, - unitPrice: itemUnitPrice, - } + if (itemName) { + itemToReturn = { name: itemName } } setCurrentlyMapped(itemToReturn) diff --git a/src/type/dto/api.dto.ts b/src/type/dto/api.dto.ts index f00aac6e..c092f7fa 100644 --- a/src/type/dto/api.dto.ts +++ b/src/type/dto/api.dto.ts @@ -4,13 +4,6 @@ export const ProductFlattenResponseSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullish(), - priceId: z.string(), - amount: z.number(), - type: z.string(), - interval: z.string().nullish(), - intervalCount: z.number().nullish(), - currency: z.string(), - createdAt: z.string().datetime(), }) export type ProductFlattenResponseType = z.infer< typeof ProductFlattenResponseSchema diff --git a/src/utils/copilotAPI.ts b/src/utils/copilotAPI.ts index 4f2cc6a4..5c065e03 100644 --- a/src/utils/copilotAPI.ts +++ b/src/utils/copilotAPI.ts @@ -397,11 +397,15 @@ export class CopilotAPI { ) } - async _getProducts( - name?: string, - nextToken?: string, - limit?: number, - ): Promise { + async _getProducts({ + name, + nextToken, + limit, + }: { + name?: string + nextToken?: string + limit?: number + }): Promise { console.info('CopilotAPI#getProducts | token =', this.token) return ProductsResponseSchema.parse( await this.copilot.listProducts({ name, nextToken, limit }), diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index c1b0ffc2..df70e06b 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -76,7 +76,6 @@ const baseProductSync: InferInsertModel = { productId: '2cf93cf0-45fa-485f-b584-03c2c38a3999', name: 'Test Product', copilotName: 'Test Product', - unitPrice: '60000.00', qbItemId: '999', qbSyncToken: '0', } diff --git a/test/integration/quickbooks/invoiceCreated/lineAmountUnitPrice.test.ts b/test/integration/quickbooks/invoiceCreated/lineAmountUnitPrice.test.ts index 4a21989f..5011e35a 100644 --- a/test/integration/quickbooks/invoiceCreated/lineAmountUnitPrice.test.ts +++ b/test/integration/quickbooks/invoiceCreated/lineAmountUnitPrice.test.ts @@ -21,7 +21,7 @@ describe('POST /api/quickbooks/webhook — invoice.created (UnitPrice comes from it('bills the line at the line amount even when it differs from the mapped item price', async () => { await seedHealthyPortal() - await seedProductSync() // qbItemId '999', stored unitPrice '60000.00' + await seedProductSync() // qbItemId '999' const payload = { ...invoiceCreatedPayload, diff --git a/test/integration/quickbooks/productMap/productKeyed.test.ts b/test/integration/quickbooks/productMap/productKeyed.test.ts index 94496c05..9e62b31e 100644 --- a/test/integration/quickbooks/productMap/productKeyed.test.ts +++ b/test/integration/quickbooks/productMap/productKeyed.test.ts @@ -30,7 +30,6 @@ describe('POST /api/quickbooks/product/map — product-keyed upsert', () => { id: '777', name: 'Test Product', syncToken: '0', - numericPrice: 1499, description: '', }, }, diff --git a/test/integration/quickbooks/productMap/reFiredInitialSave.test.ts b/test/integration/quickbooks/productMap/reFiredInitialSave.test.ts new file mode 100644 index 00000000..240f812a --- /dev/null +++ b/test/integration/quickbooks/productMap/reFiredInitialSave.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBProductSync } from '@/db/schema/qbProductSync' + +import { seedHealthyPortal } from '@test/helpers/seed' +import { setupProductCreatedTest } from '@test/helpers/productCreatedTestSetup' +import { postProductMap } from '@test/helpers/productMap' + +const PRODUCT_ID = '2cf93cf0-45fa-485f-b584-03c2c38a3999' + +describe('POST /api/quickbooks/product/map — repeated initial save', () => { + // Reuse the harness for truncate + mock install (mocks auth). + setupProductCreatedTest() + + it('returns the saved mapping instead of an empty list when the same initial save is sent twice', async () => { + // initialProductSettingMap=false keeps both POSTs on the initial-insert + // path, so the second one collides with the (portal_id, product_id) unique + // index and is skipped by onConflictDoNothing. + await seedHealthyPortal({ setting: { initialProductSettingMap: false } }) + + const body = { + mappingItems: [ + { + productId: PRODUCT_ID, + name: 'Test Product', + copilotName: 'Test Product', + description: '', + qbItemId: '999', + qbSyncToken: '0', + isExcluded: false, + }, + ], + changedItemReference: [], + } + + const first = await postProductMap(body) + expect(first.status).toBe(200) + + const second = await postProductMap(body) + expect(second.status).toBe(200) + + // The re-fired save no-ops on the conflict but must still return the live + // mapping, not the [] that a bare RETURNING would yield for skipped rows. + const secondMapping = await second.json() + expect(secondMapping).toHaveLength(1) + expect(secondMapping[0].productId).toBe(PRODUCT_ID) + + // And it must not have created a duplicate row. + const rows = await db + .select() + .from(QBProductSync) + .where(eq(QBProductSync.productId, PRODUCT_ID)) + expect(rows).toHaveLength(1) + }) +})