Phase 3: sync active Etsy listings into the product catalog

- syncListings pages active listings, fetches each listing's inventory,
  and upserts Products (one per size variation, collapsing colour).
  Title/description/price/tags/SKU/quantity and etsyListingId are
  updated; printingCost/costOfGoods are preserved (user-owned). Old
  titles are kept as aliases so historical order matching still works.
- POST /api/etsy/sync-catalog endpoint; Settings gains a 'Sync Catalog'
  button that refreshes products after running.
- Uses existing listings_r scope (no reconnect needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-13 18:48:12 +10:00
parent a470707fae
commit 2b15fd505e
3 changed files with 234 additions and 1 deletions

View file

@ -2,8 +2,9 @@ import { useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { DataManager } from '../utils/dataManager';
import { setOrders } from '../store/slices/orderSlice';
import { setProducts } from '../store/slices/productSlice';
import { MissingProductsModal } from '../components/MissingProductsModal';
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench } from 'lucide-react';
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench, Package } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../utils/api';
@ -40,6 +41,7 @@ const Settings = () => {
const [isSavingConfig, setIsSavingConfig] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [isSyncingCatalog, setIsSyncingCatalog] = useState(false);
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
const [showResolveModal, setShowResolveModal] = useState(false);
const [unknownDebits, setUnknownDebits] = useState<string[]>([]);
@ -165,6 +167,21 @@ const Settings = () => {
}
};
const handleSyncCatalog = async () => {
setIsSyncingCatalog(true);
try {
const res = await api.post('/etsy/sync-catalog');
const { listingsSeen, productsCreated, productsUpdated } = res.data;
toast.success(`Catalog synced: ${listingsSeen} listings → ${productsCreated} new, ${productsUpdated} updated products`);
const productsRes = await api.get('/products?limit=1000');
dispatch(setProducts(productsRes.data.products));
} catch (error: any) {
toast.error(error.response?.data?.message || 'Catalog sync failed');
} finally {
setIsSyncingCatalog(false);
}
};
const handleEtsyDisconnect = async () => {
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
try {
@ -245,6 +262,15 @@ const Settings = () => {
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
{isSyncing ? 'Syncing...' : 'Sync Orders Now'}
</button>
<button
onClick={handleSyncCatalog}
disabled={isSyncingCatalog}
title="Import active listings into your product catalog (one product per size; costs are kept)"
className="flex items-center gap-2 px-4 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 disabled:bg-gray-200"
>
<Package className={`w-4 h-4 ${isSyncingCatalog ? 'animate-spin' : ''}`} />
{isSyncingCatalog ? 'Syncing...' : 'Sync Catalog'}
</button>
<button
onClick={handleEtsyDisconnect}
className="flex items-center gap-2 px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"

View file

@ -12,6 +12,7 @@ import {
syncReceipts,
syncLedgerEntries,
dedupeLedgerExpenses,
syncListings,
} from '../services/etsyApi';
const router = Router();
@ -238,6 +239,24 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
}
});
// Sync the active Etsy listings into the product catalog (one product per
// size variation). Updates everything except costs, which stay user-owned.
router.post('/sync-catalog', authenticate, async (req: AuthRequest, res: Response) => {
try {
const connection = await EtsyConnection.findOne({ userId: req.userId });
if (!connection) return res.status(400).json({ message: 'Etsy is not connected' });
const config = await loadConfig(req.userId!);
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
const result = await syncListings(toCredentials(config), connection);
res.json(result);
} catch (err: any) {
console.error('Etsy catalog sync failed:', err);
res.status(500).json({ message: err.message || 'Etsy catalog sync failed' });
}
});
// Remove fee expenses imported from the statement CSV before ledger sync,
// so the ledger becomes the single source of Etsy fees (no double counting)
router.delete('/legacy-fees', authenticate, async (req: AuthRequest, res: Response) => {

View file

@ -494,3 +494,191 @@ export const dedupeLedgerExpenses = async (userId: any): Promise<number> => {
const res = await Expense.deleteMany({ _id: { $in: toDelete }, userId });
return res.deletedCount || 0;
};
// ---------- Active listings → Product catalog sync ----------
export interface CatalogSyncResult {
listingsSeen: number;
productsCreated: number;
productsUpdated: number;
}
const normTitle = (t: string): string => t.replace(/\s+/g, ' ').trim().toLowerCase();
// Size token from a title ("... Size: Large" → "large"), with large/small fallback
const titleSize = (text: string): string => {
const lower = text.toLowerCase();
const m = lower.match(/size:\s*([a-z0-9]+)/);
if (m) return m[1];
if (lower.includes('large') && !lower.includes('small')) return 'large';
if (lower.includes('small') && !lower.includes('large')) return 'small';
return '';
};
// The size value of an inventory product, from its "Size" variation property
const inventorySize = (invProduct: any): string => {
const sizeProp = (invProduct.property_values || []).find((pv: any) =>
/size/i.test(pv.property_name || '')
);
return sizeProp?.values?.[0] || '';
};
interface ListingProductData {
listingId: string;
title: string;
description: string;
tags: string[];
price: number;
quantity: number;
sku?: string;
}
const upsertCatalogProduct = async (
connection: IEtsyConnection,
data: ListingProductData,
result: CatalogSyncResult
): Promise<void> => {
const candidates = await Product.find({
userId: connection.userId,
$or: [
{ etsyListingId: data.listingId },
{ title: data.title },
{ aliases: data.title },
],
});
const wantedSize = titleSize(data.title);
const existing =
candidates.find(p => p.etsyListingId === data.listingId && titleSize(p.title) === wantedSize) ||
candidates.find(p =>
normTitle(p.title) === normTitle(data.title) ||
(p.aliases || []).some((a: string) => normTitle(a) === normTitle(data.title))
);
if (existing) {
// Update everything except costs; keep old title as an alias so historical
// order matching by that title still resolves
const aliases = new Set(existing.aliases || []);
if (normTitle(existing.title) !== normTitle(data.title)) aliases.add(existing.title);
await Product.findByIdAndUpdate(existing._id, {
$set: {
title: data.title,
description: data.description,
price: data.price,
tags: data.tags,
sku: data.sku || existing.sku || '',
etsyListingId: data.listingId,
aliases: Array.from(aliases),
'inventory.quantity': data.quantity,
},
});
result.productsUpdated++;
} else {
await Product.create({
userId: connection.userId,
title: data.title,
description: data.description,
price: data.price,
tags: data.tags,
sku: data.sku || '',
category: 'Other',
etsyListingId: data.listingId,
aliases: [],
costOfGoods: 0,
printingCost: 0,
inventory: { quantity: data.quantity, lowStockAlert: 5 },
isActive: true,
});
result.productsCreated++;
}
};
const upsertProductsFromListing = async (
creds: EtsyCredentials,
connection: IEtsyConnection,
listing: any,
result: CatalogSyncResult
): Promise<void> => {
const listingId = String(listing.listing_id);
const baseTitle = listing.title || `Etsy listing ${listingId}`;
const description = listing.description || '';
const tags = listing.tags || [];
const listingPrice = money(listing.price);
let inventory: any = null;
try {
inventory = await etsyGet(creds, connection, `/application/listings/${listingId}/inventory`);
} catch {
// Listing may have no inventory record; treated as a single product below
}
const invProducts = (inventory?.products || []).filter((p: any) => !p.is_deleted);
// Group inventory by size (collapsing colour and other variations)
const groups = new Map<string, { size: string; quantity: number; price: number; sku?: string }>();
if (invProducts.length > 0) {
for (const ip of invProducts) {
const size = inventorySize(ip);
const key = size.toLowerCase();
const offerings = (ip.offerings || []).filter((o: any) => !o.is_deleted);
const qty = offerings.reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const price = offerings.length ? money(offerings[0].price) : listingPrice;
const g = groups.get(key);
if (g) {
g.quantity += qty;
if (!g.sku && ip.sku) g.sku = ip.sku;
} else {
groups.set(key, { size, quantity: qty, price: price || listingPrice, sku: ip.sku || undefined });
}
}
} else {
groups.set('', { size: '', quantity: listing.quantity || 0, price: listingPrice });
}
for (const g of groups.values()) {
await upsertCatalogProduct(
connection,
{
listingId,
title: g.size ? `${baseTitle} Size: ${g.size}` : baseTitle,
description,
tags,
price: g.price,
quantity: g.quantity,
sku: g.sku,
},
result
);
}
};
export const syncListings = async (
creds: EtsyCredentials,
connection: IEtsyConnection
): Promise<CatalogSyncResult> => {
const result: CatalogSyncResult = { listingsSeen: 0, productsCreated: 0, productsUpdated: 0 };
const limit = 100;
let offset = 0;
while (true) {
const page = await etsyGet(
creds,
connection,
`/application/shops/${connection.shopId}/listings/active?limit=${limit}&offset=${offset}`
);
const listings: any[] = page.results || [];
if (listings.length === 0) break;
for (const listing of listings) {
result.listingsSeen++;
await upsertProductsFromListing(creds, connection, listing, result);
}
offset += listings.length;
if (offset >= (page.count || 0)) break;
}
return result;
};