From 715562c96a6c13da40876976965ecf3d9e9d836c Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Sat, 13 Jun 2026 07:54:41 +1000 Subject: [PATCH] Add Etsy API integration: OAuth connect and receipt-to-order sync - OAuth 2.0 authorization code flow with PKCE; /api/etsy/connect returns the consent URL, /api/etsy/callback exchanges the code (validated via one-time state, no JWT) and stores tokens per user with automatic refresh - /api/etsy/sync pulls all shop receipts and upserts orders by receipt id: items with SKU/variations, totals, shipping address, tracking, and status, with catalog costs snapshotted at sync time - Product matching by exact title/alias first, then etsyListingId (size-disambiguated); listing ids are learned onto products on first match - Packing-slip items with cost data are preserved when synced items can't all be matched - Settings page: Connect Etsy Shop, Sync Orders Now, Disconnect, and a list of unmatched item titles after each sync - Requires ETSY_API_KEY and ETSY_REDIRECT_URI env vars (see .env.example) Co-Authored-By: Claude Fable 5 --- client/src/pages/Settings.tsx | 157 +++++++++++++- server/.env.example | 8 +- server/src/index.ts | 4 + server/src/models/EtsyConnection.ts | 27 +++ server/src/routes/etsy.ts | 121 +++++++++++ server/src/services/etsyApi.ts | 311 ++++++++++++++++++++++++++++ 6 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 server/src/models/EtsyConnection.ts create mode 100644 server/src/routes/etsy.ts create mode 100644 server/src/services/etsyApi.ts diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 58e5cca..6e409bd 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -1,9 +1,20 @@ import { useState, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import { DataManager } from '../utils/dataManager'; -import { Trash2, Download, RefreshCw, AlertTriangle, Database } from 'lucide-react'; +import { setOrders } from '../store/slices/orderSlice'; +import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink } from 'lucide-react'; import toast from 'react-hot-toast'; +import api from '../utils/api'; + +interface EtsyStatus { + connected: boolean; + shopId?: string; + shopName?: string; + lastSyncedAt?: string; +} const Settings = () => { + const dispatch = useDispatch(); const [storageSummary, setStorageSummary] = useState({ orders: 0, products: 0, @@ -12,11 +23,79 @@ const Settings = () => { totalItems: 0 }); const [showClearConfirm, setShowClearConfirm] = useState(false); + const [etsyStatus, setEtsyStatus] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [unmatchedItems, setUnmatchedItems] = useState([]); useEffect(() => { updateStorageSummary(); + loadEtsyStatus(); + + // Handle the redirect back from the Etsy consent screen + const params = new URLSearchParams(window.location.search); + const etsyResult = params.get('etsy'); + if (etsyResult === 'connected') { + toast.success('Etsy shop connected!'); + window.history.replaceState({}, '', window.location.pathname); + } else if (etsyResult === 'error') { + toast.error('Etsy connection failed. Please try again.'); + window.history.replaceState({}, '', window.location.pathname); + } }, []); + const loadEtsyStatus = async () => { + try { + const res = await api.get('/etsy/status'); + setEtsyStatus(res.data); + } catch { + setEtsyStatus(null); + } + }; + + const handleEtsyConnect = async () => { + setIsConnecting(true); + try { + const res = await api.get('/etsy/connect'); + window.location.href = res.data.url; + } catch (error: any) { + toast.error(error.response?.data?.message || 'Failed to start Etsy connection'); + setIsConnecting(false); + } + }; + + const handleEtsySync = async () => { + setIsSyncing(true); + setUnmatchedItems([]); + try { + const res = await api.post('/etsy/sync'); + const { created, updated, unmatchedItems: unmatched, receiptsSeen } = res.data; + toast.success(`Synced ${receiptsSeen} Etsy orders: ${created} new, ${updated} updated`); + if (unmatched?.length > 0) { + setUnmatchedItems(unmatched); + } + // Refresh orders in the app + const ordersRes = await api.get('/orders?limit=1000'); + dispatch(setOrders(ordersRes.data.orders)); + loadEtsyStatus(); + } catch (error: any) { + toast.error(error.response?.data?.message || 'Etsy sync failed'); + } finally { + setIsSyncing(false); + } + }; + + const handleEtsyDisconnect = async () => { + if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return; + try { + await api.delete('/etsy/connection'); + setEtsyStatus({ connected: false }); + toast.success('Etsy disconnected'); + } catch { + toast.error('Failed to disconnect Etsy'); + } + }; + const updateStorageSummary = () => { setStorageSummary(DataManager.getStorageSummary()); }; @@ -56,7 +135,81 @@ const Settings = () => { return (

Settings

- + + {/* Etsy Integration Section */} +
+

+ + Etsy Integration +

+ + {etsyStatus?.connected ? ( +
+
+
+

+ Connected{etsyStatus.shopName ? ` to ${etsyStatus.shopName}` : ''} +

+

+ {etsyStatus.lastSyncedAt + ? `Last synced ${new Date(etsyStatus.lastSyncedAt).toLocaleString('en-AU')}` + : 'Not synced yet'} +

+
+
+ + +
+
+ + {unmatchedItems.length > 0 && ( +
+

+ {unmatchedItems.length} item title(s) couldn't be matched to your product catalog + (their orders were synced without cost data): +

+
    + {unmatchedItems.map((title, idx) => ( +
  • {title}
  • + ))} +
+

+ Add these as products (or aliases on existing products) and sync again to fill in costs. +

+
+ )} +
+ ) : ( +
+

+ Connect your Etsy shop to sync orders automatically — order details, items, + variations, and tracking, with costs matched from your product catalog. +

+ +
+ )} +
+ {/* Data Management Section */}

diff --git a/server/.env.example b/server/.env.example index d5284c6..9543429 100644 --- a/server/.env.example +++ b/server/.env.example @@ -10,4 +10,10 @@ MONGODB_URI=mongodb://localhost:27017/etsy-tracker JWT_SECRET=your-super-secret-jwt-key-change-this-in-production # Session Secret (change in production) -SESSION_SECRET=your-super-secret-session-key-change-this-in-production \ No newline at end of file +SESSION_SECRET=your-super-secret-session-key-change-this-in-production + +# Etsy API (personal-use app from etsy.com/developers) +# ETSY_API_KEY is the app keystring; ETSY_REDIRECT_URI must exactly match a +# callback URL registered in the Etsy app settings +ETSY_API_KEY=your-etsy-keystring +ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts index 419cdd2..edc5f29 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import orderRoutes from './routes/orders'; import customerRoutes from './routes/customers'; import expenseRoutes from './routes/expenses'; import analyticsRoutes from './routes/analytics'; +import etsyRoutes from './routes/etsy'; import { authenticate } from './middleware/authenticate'; // Load environment variables @@ -66,6 +67,9 @@ app.use('/api/orders', authenticate, orderRoutes); app.use('/api/customers', authenticate, customerRoutes); app.use('/api/expenses', authenticate, expenseRoutes); app.use('/api/analytics', authenticate, analyticsRoutes); +// Etsy routes handle auth per-route: /callback is a browser redirect from Etsy +// (no JWT) and is validated via the one-time OAuth state instead +app.use('/api/etsy', etsyRoutes); // Health check endpoint app.get('/api/health', (req, res) => { diff --git a/server/src/models/EtsyConnection.ts b/server/src/models/EtsyConnection.ts new file mode 100644 index 0000000..a42a890 --- /dev/null +++ b/server/src/models/EtsyConnection.ts @@ -0,0 +1,27 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export interface IEtsyConnection extends Document { + userId: mongoose.Types.ObjectId; + accessToken: string; + refreshToken: string; + tokenExpiresAt: Date; + etsyUserId: string; + shopId: string; + shopName?: string; + lastSyncedAt?: Date; + dateCreated: Date; +} + +const EtsyConnectionSchema: Schema = new Schema({ + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true, index: true }, + accessToken: { type: String, required: true }, + refreshToken: { type: String, required: true }, + tokenExpiresAt: { type: Date, required: true }, + etsyUserId: { type: String, required: true }, + shopId: { type: String, required: true }, + shopName: { type: String }, + lastSyncedAt: { type: Date }, + dateCreated: { type: Date, default: Date.now }, +}); + +export default mongoose.model('EtsyConnection', EtsyConnectionSchema); diff --git a/server/src/routes/etsy.ts b/server/src/routes/etsy.ts new file mode 100644 index 0000000..a769eec --- /dev/null +++ b/server/src/routes/etsy.ts @@ -0,0 +1,121 @@ +import { Router, Response, Request } from 'express'; +import crypto from 'crypto'; +import { authenticate, AuthRequest } from '../middleware/authenticate'; +import EtsyConnection from '../models/EtsyConnection'; +import { + buildAuthUrl, + exchangeCode, + fetchIdentity, + generatePkce, + syncReceipts, +} from '../services/etsyApi'; + +const router = Router(); + +// Pending OAuth attempts keyed by state; ties the browser callback (which has +// no JWT) back to the user who initiated the connect. Single-process only. +const pendingAuth = new Map(); +const PENDING_TTL_MS = 10 * 60 * 1000; + +const prunePending = () => { + const cutoff = Date.now() - PENDING_TTL_MS; + for (const [state, entry] of pendingAuth) { + if (entry.createdAt < cutoff) pendingAuth.delete(state); + } +}; + +const redirectUri = (): string => { + const uri = process.env.ETSY_REDIRECT_URI; + if (!uri) throw new Error('ETSY_REDIRECT_URI is not configured on the server'); + return uri; +}; + +const settingsUrl = (suffix: string): string => + `${process.env.CLIENT_URL || 'http://localhost:3000'}/settings?etsy=${suffix}`; + +// Start the OAuth flow: returns the Etsy consent URL for the client to open +router.get('/connect', authenticate, (req: AuthRequest, res: Response) => { + try { + prunePending(); + const state = crypto.randomBytes(16).toString('hex'); + const { verifier, challenge } = generatePkce(); + pendingAuth.set(state, { userId: req.userId!, verifier, createdAt: Date.now() }); + res.json({ url: buildAuthUrl(redirectUri(), state, challenge) }); + } catch (err: any) { + res.status(500).json({ message: err.message || 'Failed to start Etsy connection' }); + } +}); + +// OAuth redirect target — called by the browser coming back from Etsy +router.get('/callback', async (req: Request, res: Response) => { + const { code, state, error } = req.query; + const pending = typeof state === 'string' ? pendingAuth.get(state) : undefined; + + if (error || typeof code !== 'string' || !pending) { + return res.redirect(settingsUrl('error')); + } + pendingAuth.delete(String(state)); + + try { + const tokens = await exchangeCode(code, pending.verifier, redirectUri()); + const identity = await fetchIdentity(tokens.access_token); + + await EtsyConnection.findOneAndUpdate( + { userId: pending.userId }, + { + userId: pending.userId, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), + etsyUserId: identity.etsyUserId, + shopId: identity.shopId, + shopName: identity.shopName, + }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + res.redirect(settingsUrl('connected')); + } catch (err) { + console.error('Etsy OAuth callback failed:', err); + res.redirect(settingsUrl('error')); + } +}); + +router.get('/status', authenticate, async (req: AuthRequest, res: Response) => { + try { + const connection = await EtsyConnection.findOne({ userId: req.userId }); + if (!connection) return res.json({ connected: false }); + res.json({ + connected: true, + shopId: connection.shopId, + shopName: connection.shopName, + lastSyncedAt: connection.lastSyncedAt, + }); + } catch (err) { + res.status(500).json({ message: 'Failed to fetch Etsy status', error: err }); + } +}); + +router.post('/sync', 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 result = await syncReceipts(connection); + res.json(result); + } catch (err: any) { + console.error('Etsy sync failed:', err); + res.status(500).json({ message: err.message || 'Etsy sync failed' }); + } +}); + +router.delete('/connection', authenticate, async (req: AuthRequest, res: Response) => { + try { + await EtsyConnection.findOneAndDelete({ userId: req.userId }); + res.json({ message: 'Etsy disconnected' }); + } catch (err) { + res.status(500).json({ message: 'Failed to disconnect Etsy', error: err }); + } +}); + +export default router; diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts new file mode 100644 index 0000000..3d7caf1 --- /dev/null +++ b/server/src/services/etsyApi.ts @@ -0,0 +1,311 @@ +import crypto from 'crypto'; +import { IEtsyConnection } from '../models/EtsyConnection'; +import Order from '../models/Order'; +import Product, { IProduct } from '../models/Product'; + +const ETSY_API_BASE = 'https://api.etsy.com/v3'; +const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token'; +const ETSY_CONNECT_URL = 'https://www.etsy.com/oauth/connect'; + +// Read scopes: receipts/transactions, listings, shop info +export const ETSY_SCOPES = 'transactions_r listings_r shops_r'; + +const apiKey = (): string => { + const key = process.env.ETSY_API_KEY; + if (!key) throw new Error('ETSY_API_KEY is not configured on the server'); + return key; +}; + +// ---------- OAuth (authorization code + PKCE) ---------- + +export const generatePkce = () => { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +}; + +export const buildAuthUrl = (redirectUri: string, state: string, codeChallenge: string): string => { + const params = new URLSearchParams({ + response_type: 'code', + client_id: apiKey(), + redirect_uri: redirectUri, + scope: ETSY_SCOPES, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + return `${ETSY_CONNECT_URL}?${params.toString()}`; +}; + +interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; +} + +const requestToken = async (body: Record): Promise => { + const res = await fetch(ETSY_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`Etsy token request failed (${res.status}): ${await res.text()}`); + } + return res.json() as Promise; +}; + +export const exchangeCode = (code: string, codeVerifier: string, redirectUri: string) => + requestToken({ + grant_type: 'authorization_code', + client_id: apiKey(), + redirect_uri: redirectUri, + code, + code_verifier: codeVerifier, + }); + +const refreshTokens = (refreshToken: string) => + requestToken({ + grant_type: 'refresh_token', + client_id: apiKey(), + refresh_token: refreshToken, + }); + +// Refresh the connection's access token if it expires within the next minute +const ensureFreshToken = async (connection: IEtsyConnection): Promise => { + if (connection.tokenExpiresAt.getTime() > Date.now() + 60_000) return; + + const tokens = await refreshTokens(connection.refreshToken); + connection.accessToken = tokens.access_token; + connection.refreshToken = tokens.refresh_token; + connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000); + await connection.save(); +}; + +// ---------- API client ---------- + +const etsyGet = async (connection: IEtsyConnection, path: string): Promise => { + await ensureFreshToken(connection); + const res = await fetch(`${ETSY_API_BASE}${path}`, { + headers: { + 'x-api-key': apiKey(), + Authorization: `Bearer ${connection.accessToken}`, + }, + }); + if (!res.ok) { + throw new Error(`Etsy API ${path} failed (${res.status}): ${await res.text()}`); + } + return res.json(); +}; + +export interface EtsyIdentity { + etsyUserId: string; + shopId: string; + shopName?: string; +} + +// Resolve the connected user's shop from a fresh access token +export const fetchIdentity = async (accessToken: string): Promise => { + const res = await fetch(`${ETSY_API_BASE}/application/users/me`, { + headers: { 'x-api-key': apiKey(), Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + throw new Error(`Etsy users/me failed (${res.status}): ${await res.text()}`); + } + const me: any = await res.json(); + if (!me.shop_id) { + throw new Error('The connected Etsy account has no shop'); + } + + let shopName: string | undefined; + try { + const shopRes = await fetch(`${ETSY_API_BASE}/application/shops/${me.shop_id}`, { + headers: { 'x-api-key': apiKey(), Authorization: `Bearer ${accessToken}` }, + }); + if (shopRes.ok) { + const shop: any = await shopRes.json(); + shopName = shop.shop_name; + } + } catch { + // Shop name is cosmetic; ignore failures + } + + return { etsyUserId: String(me.user_id), shopId: String(me.shop_id), shopName }; +}; + +// ---------- Receipt → Order sync ---------- + +// Etsy money objects are { amount, divisor, currency_code } +const money = (m: any): number => (m && m.divisor ? m.amount / m.divisor : 0); + +const normalizeTitle = (title: string): string => + title.replace(/\s+/g, ' ').trim().toLowerCase(); + +const sizeToken = (text: string): string => { + const lower = text.toLowerCase(); + if (lower.includes('large') && !lower.includes('small')) return 'large'; + if (lower.includes('small') && !lower.includes('large')) return 'small'; + if (lower.includes('med')) return 'med'; + return ''; +}; + +// Match an Etsy transaction to a catalog product: +// 1. exact title/alias match (including "title variations" form) +// 2. listing id match, disambiguated by size when several products share it +const matchProduct = ( + products: IProduct[], + transaction: any +): IProduct | undefined => { + const variantStr = (transaction.variations || []) + .map((v: any) => `${v.formatted_name}: ${v.formatted_value}`) + .join(', '); + const candidatesByTitle = [ + transaction.title || '', + variantStr ? `${transaction.title} ${variantStr}` : '', + ].filter(Boolean).map(normalizeTitle); + + for (const product of products) { + const names = [product.title, ...(product.aliases || [])].map(normalizeTitle); + if (candidatesByTitle.some(c => names.includes(c))) return product; + } + + if (transaction.listing_id) { + const listingId = String(transaction.listing_id); + const byListing = products.filter(p => p.etsyListingId === listingId); + if (byListing.length === 1) return byListing[0]; + if (byListing.length > 1) { + const size = sizeToken(`${transaction.title || ''} ${variantStr}`); + if (size) { + const bySize = byListing.filter(p => sizeToken(p.title) === size); + if (bySize.length === 1) return bySize[0]; + } + } + } + + return undefined; +}; + +const mapStatus = (receipt: any): string => { + const status = String(receipt.status || '').toLowerCase(); + if (status === 'canceled') return 'cancelled'; + if (status === 'completed') return 'delivered'; + if (receipt.is_shipped) return 'shipped'; + return 'processing'; +}; + +export interface SyncResult { + created: number; + updated: number; + unmatchedItems: string[]; + receiptsSeen: number; +} + +export const syncReceipts = async (connection: IEtsyConnection): Promise => { + const products = await Product.find({ userId: connection.userId }); + const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 }; + + const limit = 100; + let offset = 0; + + while (true) { + const page = await etsyGet( + connection, + `/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}` + ); + const receipts: any[] = page.results || []; + if (receipts.length === 0) break; + + for (const receipt of receipts) { + result.receiptsSeen++; + await upsertOrderFromReceipt(connection, receipt, products, result); + } + + offset += receipts.length; + if (offset >= (page.count || 0)) break; + } + + connection.lastSyncedAt = new Date(); + await connection.save(); + return result; +}; + +const upsertOrderFromReceipt = async ( + connection: IEtsyConnection, + receipt: any, + products: IProduct[], + result: SyncResult +): Promise => { + const orderNumber = String(receipt.receipt_id); + + // Snapshot catalog costs onto items at sync time (same model as PDF import) + let allMatched = true; + const items = (receipt.transactions || []).map((transaction: any) => { + const product = matchProduct(products, transaction); + if (!product) { + allMatched = false; + if (!result.unmatchedItems.includes(transaction.title)) { + result.unmatchedItems.push(transaction.title); + } + } else if (!product.etsyListingId && transaction.listing_id) { + // Learn the listing id for future deterministic matching + product.etsyListingId = String(transaction.listing_id); + product.save().catch(() => {}); + } + + const variantStr = (transaction.variations || []) + .map((v: any) => `${v.formatted_name}: ${v.formatted_value}`) + .join(', '); + + return { + title: transaction.title, + quantity: transaction.quantity || 1, + price: money(transaction.price), + sku: transaction.sku || undefined, + variant: variantStr || undefined, + productId: product?._id, + printingCost: product?.printingCost || 0, + costOfGoods: product?.costOfGoods || 0, + }; + }); + + const shipment = (receipt.shipments || [])[0]; + + const orderData: any = { + orderNumber, + etsyOrderId: orderNumber, + total: money(receipt.grandtotal), + subtotal: money(receipt.subtotal), + shipping: money(receipt.total_shipping_cost), + tax: money(receipt.total_tax_cost), + status: mapStatus(receipt), + paymentStatus: receipt.is_paid ? 'paid' : 'pending', + dateOrdered: new Date((receipt.created_timestamp || receipt.create_timestamp) * 1000), + customer: { name: receipt.name || 'Etsy Customer', email: '' }, + shippingAddress: { + name: receipt.name, + street1: receipt.first_line, + street2: receipt.second_line, + city: receipt.city, + state: receipt.state, + zipCode: receipt.zip, + country: receipt.country_iso, + }, + trackingNumber: shipment?.tracking_code, + shippingCarrier: shipment?.carrier_name, + }; + + const existing = await Order.findOne({ orderNumber, userId: connection.userId }); + if (existing) { + // Keep richer existing items (e.g. from packing-slip import with costs) + // unless every synced item matched a catalog product + const keepExistingItems = !allMatched && existing.items && existing.items.length > 0; + if (!keepExistingItems) orderData.items = items; + + await Order.findByIdAndUpdate(existing._id, orderData, { runValidators: true }); + result.updated++; + } else { + orderData.items = items; + await Order.create({ ...orderData, userId: connection.userId }); + result.created++; + } +};