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:
dlawler489 2026-06-13 15:16:15 +10:00
parent 46d1ca3375
commit 03979a9b48
3 changed files with 225 additions and 3 deletions

View file

@ -42,6 +42,8 @@ const Settings = () => {
const [isSyncing, setIsSyncing] = useState(false);
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
const [showResolveModal, setShowResolveModal] = useState(false);
const [unknownDebits, setUnknownDebits] = useState<string[]>([]);
const [legacyEtsyExpenses, setLegacyEtsyExpenses] = useState(0);
useEffect(() => {
updateStorageSummary();
@ -122,10 +124,16 @@ const Settings = () => {
const handleEtsySync = async () => {
setIsSyncing(true);
setUnmatchedItems([]);
setUnknownDebits([]);
try {
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`);
if (ledger) {
toast.success(`Fees from Etsy ledger: ${ledger.feesCreated} new expense(s) imported`);
setUnknownDebits(ledger.unknownDebits || []);
}
setLegacyEtsyExpenses(legacy || 0);
if (unmatched?.length > 0) {
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 () => {
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
try {
@ -256,6 +278,45 @@ const Settings = () => {
</p>
</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 className="space-y-4">

View file

@ -3,12 +3,14 @@ import crypto from 'crypto';
import { authenticate, AuthRequest } from '../middleware/authenticate';
import EtsyConnection from '../models/EtsyConnection';
import EtsySettings from '../models/EtsySettings';
import Expense from '../models/Expense';
import {
buildAuthUrl,
exchangeCode,
fetchIdentity,
generatePkce,
syncReceipts,
syncLedgerEntries,
} from '../services/etsyApi';
const router = Router();
@ -213,14 +215,41 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
const config = await loadConfig(req.userId!);
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
const result = await syncReceipts(toCredentials(config), connection);
res.json(result);
const creds = toCredentials(config);
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) {
console.error('Etsy sync failed:', err);
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) => {
try {
await EtsyConnection.findOneAndDelete({ userId: req.userId });

View file

@ -2,6 +2,7 @@ import crypto from 'crypto';
import { IEtsyConnection } from '../models/EtsyConnection';
import Order from '../models/Order';
import Product, { IProduct } from '../models/Product';
import Expense from '../models/Expense';
const ETSY_API_BASE = 'https://api.etsy.com/v3';
const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token';
@ -319,3 +320,134 @@ const upsertOrderFromReceipt = async (
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;
};