Phase 2: sync Etsy payment ledger into expenses (exact fees)
- syncLedgerEntries pulls payment-account ledger entries (chunked by 90d over required min_created/max_created), classifies debits into fee categories, and inserts them as idempotent expenses (reference etsy-ledger-<entry_id>). Amounts are integer minor units -> /100. - Conservative classifier: only genuine costs become expenses; sale credits, disbursements, and refunds are skipped. Unclassified debits are reported back so rules can be refined. - Folded into POST /api/etsy/sync alongside orders; response includes ledger result and a legacyEtsyExpenses count (pre-ledger CSV fees). - DELETE /api/etsy/legacy-fees removes CSV-imported Etsy fee expenses to avoid double counting; Settings surfaces the count with a one-click remove, plus a list of skipped/unknown ledger charge types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
46d1ca3375
commit
03979a9b48
3 changed files with 225 additions and 3 deletions
|
|
@ -42,6 +42,8 @@ const Settings = () => {
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
||||||
const [showResolveModal, setShowResolveModal] = useState(false);
|
const [showResolveModal, setShowResolveModal] = useState(false);
|
||||||
|
const [unknownDebits, setUnknownDebits] = useState<string[]>([]);
|
||||||
|
const [legacyEtsyExpenses, setLegacyEtsyExpenses] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateStorageSummary();
|
updateStorageSummary();
|
||||||
|
|
@ -122,10 +124,16 @@ const Settings = () => {
|
||||||
const handleEtsySync = async () => {
|
const handleEtsySync = async () => {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
setUnmatchedItems([]);
|
setUnmatchedItems([]);
|
||||||
|
setUnknownDebits([]);
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/etsy/sync');
|
const res = await api.post('/etsy/sync');
|
||||||
const { created, updated, unmatchedItems: unmatched, receiptsSeen } = res.data;
|
const { created, updated, unmatchedItems: unmatched, receiptsSeen, ledger, legacyEtsyExpenses: legacy } = res.data;
|
||||||
toast.success(`Synced ${receiptsSeen} Etsy orders: ${created} new, ${updated} updated`);
|
toast.success(`Synced ${receiptsSeen} Etsy orders: ${created} new, ${updated} updated`);
|
||||||
|
if (ledger) {
|
||||||
|
toast.success(`Fees from Etsy ledger: ${ledger.feesCreated} new expense(s) imported`);
|
||||||
|
setUnknownDebits(ledger.unknownDebits || []);
|
||||||
|
}
|
||||||
|
setLegacyEtsyExpenses(legacy || 0);
|
||||||
if (unmatched?.length > 0) {
|
if (unmatched?.length > 0) {
|
||||||
setUnmatchedItems(unmatched);
|
setUnmatchedItems(unmatched);
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +148,20 @@ const Settings = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveLegacyFees = async () => {
|
||||||
|
if (!window.confirm(
|
||||||
|
'Remove Etsy fee expenses that were imported from the statement CSV? ' +
|
||||||
|
'The Etsy ledger sync replaces them. This avoids double-counting fees.'
|
||||||
|
)) return;
|
||||||
|
try {
|
||||||
|
const res = await api.delete('/etsy/legacy-fees');
|
||||||
|
toast.success(`Removed ${res.data.deleted} CSV-imported Etsy fee expense(s)`);
|
||||||
|
setLegacyEtsyExpenses(0);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to remove legacy Etsy fees');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEtsyDisconnect = async () => {
|
const handleEtsyDisconnect = async () => {
|
||||||
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
|
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -256,6 +278,45 @@ const Settings = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{legacyEtsyExpenses > 0 && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-amber-800 font-medium text-sm">
|
||||||
|
{legacyEtsyExpenses} Etsy fee expense(s) from the old statement-CSV import are still present.
|
||||||
|
</p>
|
||||||
|
<p className="text-amber-700 text-xs mt-1">
|
||||||
|
The ledger sync now pulls fees directly from Etsy. Remove the old CSV-imported
|
||||||
|
ones so fees aren't counted twice. (You can stop importing the Etsy statement CSV.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveLegacyFees}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 text-sm whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Remove Old Fees
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unknownDebits.length > 0 && (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<p className="text-gray-700 font-medium text-sm mb-2">
|
||||||
|
{unknownDebits.length} ledger charge type(s) weren't auto-categorised as fees and were skipped:
|
||||||
|
</p>
|
||||||
|
<ul className="text-gray-600 text-sm list-disc list-inside space-y-1">
|
||||||
|
{unknownDebits.map((d, idx) => (
|
||||||
|
<li key={idx}>{d}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-500 text-xs mt-2">
|
||||||
|
If any of these are real business costs, let me know and I'll add them to the fee rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import crypto from 'crypto';
|
||||||
import { authenticate, AuthRequest } from '../middleware/authenticate';
|
import { authenticate, AuthRequest } from '../middleware/authenticate';
|
||||||
import EtsyConnection from '../models/EtsyConnection';
|
import EtsyConnection from '../models/EtsyConnection';
|
||||||
import EtsySettings from '../models/EtsySettings';
|
import EtsySettings from '../models/EtsySettings';
|
||||||
|
import Expense from '../models/Expense';
|
||||||
import {
|
import {
|
||||||
buildAuthUrl,
|
buildAuthUrl,
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
fetchIdentity,
|
fetchIdentity,
|
||||||
generatePkce,
|
generatePkce,
|
||||||
syncReceipts,
|
syncReceipts,
|
||||||
|
syncLedgerEntries,
|
||||||
} from '../services/etsyApi';
|
} from '../services/etsyApi';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -213,14 +215,41 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
const config = await loadConfig(req.userId!);
|
const config = await loadConfig(req.userId!);
|
||||||
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
|
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
|
||||||
|
|
||||||
const result = await syncReceipts(toCredentials(config), connection);
|
const creds = toCredentials(config);
|
||||||
res.json(result);
|
const result = await syncReceipts(creds, connection);
|
||||||
|
|
||||||
|
// Also pull the payment-account ledger into expenses (exact fees from Etsy)
|
||||||
|
const ledger = await syncLedgerEntries(creds, connection);
|
||||||
|
|
||||||
|
// Warn if pre-ledger CSV-imported Etsy fees still exist (would double-count)
|
||||||
|
const legacyEtsyExpenses = await Expense.countDocuments({
|
||||||
|
userId: req.userId,
|
||||||
|
vendor: 'Etsy',
|
||||||
|
reference: { $not: /^etsy-ledger-/ },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ...result, ledger, legacyEtsyExpenses });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Etsy sync failed:', err);
|
console.error('Etsy sync failed:', err);
|
||||||
res.status(500).json({ message: err.message || 'Etsy sync failed' });
|
res.status(500).json({ message: err.message || 'Etsy 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) => {
|
||||||
|
try {
|
||||||
|
const result = await Expense.deleteMany({
|
||||||
|
userId: req.userId,
|
||||||
|
vendor: 'Etsy',
|
||||||
|
reference: { $not: /^etsy-ledger-/ },
|
||||||
|
});
|
||||||
|
res.json({ deleted: result.deletedCount });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ message: 'Failed to remove legacy Etsy fees', error: err });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/connection', authenticate, async (req: AuthRequest, res: Response) => {
|
router.delete('/connection', authenticate, async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
await EtsyConnection.findOneAndDelete({ userId: req.userId });
|
await EtsyConnection.findOneAndDelete({ userId: req.userId });
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import crypto from 'crypto';
|
||||||
import { IEtsyConnection } from '../models/EtsyConnection';
|
import { IEtsyConnection } from '../models/EtsyConnection';
|
||||||
import Order from '../models/Order';
|
import Order from '../models/Order';
|
||||||
import Product, { IProduct } from '../models/Product';
|
import Product, { IProduct } from '../models/Product';
|
||||||
|
import Expense from '../models/Expense';
|
||||||
|
|
||||||
const ETSY_API_BASE = 'https://api.etsy.com/v3';
|
const ETSY_API_BASE = 'https://api.etsy.com/v3';
|
||||||
const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token';
|
const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token';
|
||||||
|
|
@ -319,3 +320,134 @@ const upsertOrderFromReceipt = async (
|
||||||
result.created++;
|
result.created++;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- Payment ledger → Expense sync ----------
|
||||||
|
|
||||||
|
export interface LedgerSyncResult {
|
||||||
|
feesCreated: number;
|
||||||
|
feesDuplicate: number;
|
||||||
|
feesByCategory: Record<string, number>;
|
||||||
|
entriesSeen: number;
|
||||||
|
// Debit entries we couldn't confidently classify as a fee — surfaced so the
|
||||||
|
// category rules can be refined rather than silently miscategorising money
|
||||||
|
unknownDebits: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Etsy ledger amounts are integers in the entry currency's minor unit (cents);
|
||||||
|
// there is no per-entry divisor, so /100 (all Etsy currencies are 2-decimal)
|
||||||
|
const ledgerAmount = (entry: any): number => (entry.amount || 0) / 100;
|
||||||
|
|
||||||
|
// Map a debit ledger entry to an expense category, or null to skip it.
|
||||||
|
// Conservative: only genuine business costs become expenses. Credits (sales),
|
||||||
|
// disbursements to bank, and refunds (revenue reversals) are never expenses.
|
||||||
|
export const classifyLedgerEntry = (entry: any): string | null => {
|
||||||
|
if ((entry.amount || 0) >= 0) return null; // credits: sale payments, misc credits
|
||||||
|
|
||||||
|
const text = `${entry.description || ''} ${entry.ledger_type || ''} ${entry.reference_type || ''}`.toLowerCase();
|
||||||
|
|
||||||
|
// Transfers and revenue reversals are not expenses
|
||||||
|
if (/disburse|deposit|withdraw|payout/.test(text)) return null;
|
||||||
|
if (/refund|reversal|return/.test(text)) return null;
|
||||||
|
|
||||||
|
if (/listing/.test(text)) return 'Listing Fees';
|
||||||
|
if (/transaction/.test(text)) return 'Transaction Fees';
|
||||||
|
if (/processing|payment fee/.test(text)) return 'Payment Processing Fees';
|
||||||
|
if (/ads|advertis|marketing|offsite/.test(text)) return 'Marketing & Advertising';
|
||||||
|
if (/shipping|postage|label/.test(text)) return 'Shipping & Postage';
|
||||||
|
if (/vat|gst|\btax\b/.test(text)) return 'Taxes & GST';
|
||||||
|
if (/subscription|etsy plus/.test(text)) return 'Subscriptions';
|
||||||
|
if (/regulatory|operating fee|fee|bill/.test(text)) return 'Other Etsy Fees';
|
||||||
|
|
||||||
|
return null; // unknown debit — reported, not guessed
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncLedgerEntries = async (
|
||||||
|
creds: EtsyCredentials,
|
||||||
|
connection: IEtsyConnection,
|
||||||
|
// Default lookback covers the shop's full selling history; chunked to avoid
|
||||||
|
// any range limits on the endpoint
|
||||||
|
startDate: Date = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000)
|
||||||
|
): Promise<LedgerSyncResult> => {
|
||||||
|
const result: LedgerSyncResult = {
|
||||||
|
feesCreated: 0,
|
||||||
|
feesDuplicate: 0,
|
||||||
|
feesByCategory: {},
|
||||||
|
entriesSeen: 0,
|
||||||
|
unknownDebits: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const windowSeconds = 90 * 24 * 60 * 60;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
let windowStart = Math.floor(startDate.getTime() / 1000);
|
||||||
|
|
||||||
|
const toCreate: any[] = [];
|
||||||
|
|
||||||
|
while (windowStart < nowSec) {
|
||||||
|
const windowEnd = Math.min(windowStart + windowSeconds, nowSec);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const page = await etsyGet(
|
||||||
|
creds,
|
||||||
|
connection,
|
||||||
|
`/application/shops/${connection.shopId}/payment-account/ledger-entries` +
|
||||||
|
`?min_created=${windowStart}&max_created=${windowEnd}&limit=100&offset=${offset}`
|
||||||
|
);
|
||||||
|
const entries: any[] = page.results || [];
|
||||||
|
if (entries.length === 0) break;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
result.entriesSeen++;
|
||||||
|
const category = classifyLedgerEntry(entry);
|
||||||
|
if (!category) {
|
||||||
|
// Collect unclassified debits for reporting (capped)
|
||||||
|
if ((entry.amount || 0) < 0 && result.unknownDebits.length < 25) {
|
||||||
|
const desc = String(entry.description || entry.ledger_type || 'unknown').trim();
|
||||||
|
if (!result.unknownDebits.includes(desc)) result.unknownDebits.push(desc);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = new Date((entry.created_timestamp || entry.create_date) * 1000);
|
||||||
|
toCreate.push({
|
||||||
|
category,
|
||||||
|
description: String(entry.description || category),
|
||||||
|
amount: Math.abs(ledgerAmount(entry)),
|
||||||
|
date: when,
|
||||||
|
taxDeductible: true,
|
||||||
|
vendor: 'Etsy',
|
||||||
|
// Stable per-entry reference makes re-syncs idempotent via the unique
|
||||||
|
// {reference, vendor, amount, date} index
|
||||||
|
reference: `etsy-ledger-${entry.entry_id}`,
|
||||||
|
notes: entry.reference_id ? `Etsy ${entry.reference_type || 'ref'} ${entry.reference_id}` : undefined,
|
||||||
|
userId: connection.userId,
|
||||||
|
});
|
||||||
|
result.feesByCategory[category] = (result.feesByCategory[category] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += entries.length;
|
||||||
|
if (offset >= (page.count || 0)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowStart = windowEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent insert: the unique index rejects entries already imported
|
||||||
|
if (toCreate.length > 0) {
|
||||||
|
try {
|
||||||
|
const inserted = await Expense.insertMany(toCreate, { ordered: false, rawResult: true } as any);
|
||||||
|
result.feesCreated = (inserted as any).insertedCount ?? toCreate.length;
|
||||||
|
} catch (bulkError: any) {
|
||||||
|
if (bulkError.writeErrors) {
|
||||||
|
for (const e of bulkError.writeErrors) {
|
||||||
|
if (e.code === 11000) result.feesDuplicate++;
|
||||||
|
}
|
||||||
|
result.feesCreated = bulkError.result?.insertedCount ?? (toCreate.length - bulkError.writeErrors.length);
|
||||||
|
} else {
|
||||||
|
throw bulkError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue