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 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-13 07:54:41 +10:00
parent b27ede4131
commit 715562c96a
6 changed files with 625 additions and 3 deletions

View file

@ -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<EtsyStatus | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
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 (
<div className="p-6 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-6">Settings</h1>
{/* Etsy Integration Section */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Store className="w-6 h-6 text-orange-500" />
Etsy Integration
</h2>
{etsyStatus?.connected ? (
<div className="space-y-4">
<div className="flex items-center justify-between bg-green-50 border border-green-200 rounded-lg p-4">
<div>
<p className="text-green-800 font-medium">
Connected{etsyStatus.shopName ? ` to ${etsyStatus.shopName}` : ''}
</p>
<p className="text-green-700 text-sm mt-1">
{etsyStatus.lastSyncedAt
? `Last synced ${new Date(etsyStatus.lastSyncedAt).toLocaleString('en-AU')}`
: 'Not synced yet'}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleEtsySync}
disabled={isSyncing}
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
>
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
{isSyncing ? 'Syncing...' : 'Sync Orders Now'}
</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"
>
<Unlink className="w-4 h-4" />
Disconnect
</button>
</div>
</div>
{unmatchedItems.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-yellow-800 font-medium text-sm mb-2">
{unmatchedItems.length} item title(s) couldn't be matched to your product catalog
(their orders were synced without cost data):
</p>
<ul className="text-yellow-700 text-sm list-disc list-inside space-y-1">
{unmatchedItems.map((title, idx) => (
<li key={idx}>{title}</li>
))}
</ul>
<p className="text-yellow-700 text-xs mt-2">
Add these as products (or aliases on existing products) and sync again to fill in costs.
</p>
</div>
)}
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-gray-600 text-sm max-w-md">
Connect your Etsy shop to sync orders automatically order details, items,
variations, and tracking, with costs matched from your product catalog.
</p>
<button
onClick={handleEtsyConnect}
disabled={isConnecting}
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
>
<Link2 className="w-4 h-4" />
{isConnecting ? 'Redirecting…' : 'Connect Etsy Shop'}
</button>
</div>
)}
</div>
{/* Data Management Section */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">

View file

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

View file

@ -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) => {

View file

@ -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<IEtsyConnection>('EtsyConnection', EtsyConnectionSchema);

121
server/src/routes/etsy.ts Normal file
View file

@ -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<string, { userId: string; verifier: string; createdAt: number }>();
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;

View file

@ -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<string, string>): Promise<TokenResponse> => {
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<TokenResponse>;
};
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<void> => {
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<any> => {
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<EtsyIdentity> => {
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<SyncResult> => {
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<void> => {
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++;
}
};