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 [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">
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue