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:
parent
b27ede4131
commit
715562c96a
6 changed files with 625 additions and 3 deletions
|
|
@ -1,9 +1,20 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
import { DataManager } from '../utils/dataManager';
|
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 toast from 'react-hot-toast';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
interface EtsyStatus {
|
||||||
|
connected: boolean;
|
||||||
|
shopId?: string;
|
||||||
|
shopName?: string;
|
||||||
|
lastSyncedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
const [storageSummary, setStorageSummary] = useState({
|
const [storageSummary, setStorageSummary] = useState({
|
||||||
orders: 0,
|
orders: 0,
|
||||||
products: 0,
|
products: 0,
|
||||||
|
|
@ -12,11 +23,79 @@ const Settings = () => {
|
||||||
totalItems: 0
|
totalItems: 0
|
||||||
});
|
});
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
updateStorageSummary();
|
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 = () => {
|
const updateStorageSummary = () => {
|
||||||
setStorageSummary(DataManager.getStorageSummary());
|
setStorageSummary(DataManager.getStorageSummary());
|
||||||
};
|
};
|
||||||
|
|
@ -56,7 +135,81 @@ const Settings = () => {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-4xl mx-auto">
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Settings</h1>
|
<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 */}
|
{/* Data Management Section */}
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,10 @@ MONGODB_URI=mongodb://localhost:27017/etsy-tracker
|
||||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
|
|
||||||
# Session Secret (change 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
|
||||||
|
|
@ -14,6 +14,7 @@ import orderRoutes from './routes/orders';
|
||||||
import customerRoutes from './routes/customers';
|
import customerRoutes from './routes/customers';
|
||||||
import expenseRoutes from './routes/expenses';
|
import expenseRoutes from './routes/expenses';
|
||||||
import analyticsRoutes from './routes/analytics';
|
import analyticsRoutes from './routes/analytics';
|
||||||
|
import etsyRoutes from './routes/etsy';
|
||||||
import { authenticate } from './middleware/authenticate';
|
import { authenticate } from './middleware/authenticate';
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
|
|
@ -66,6 +67,9 @@ app.use('/api/orders', authenticate, orderRoutes);
|
||||||
app.use('/api/customers', authenticate, customerRoutes);
|
app.use('/api/customers', authenticate, customerRoutes);
|
||||||
app.use('/api/expenses', authenticate, expenseRoutes);
|
app.use('/api/expenses', authenticate, expenseRoutes);
|
||||||
app.use('/api/analytics', authenticate, analyticsRoutes);
|
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
|
// Health check endpoint
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
|
|
|
||||||
27
server/src/models/EtsyConnection.ts
Normal file
27
server/src/models/EtsyConnection.ts
Normal 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
121
server/src/routes/etsy.ts
Normal 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;
|
||||||
311
server/src/services/etsyApi.ts
Normal file
311
server/src/services/etsyApi.ts
Normal 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++;
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue