import { defineStore } from "pinia"; import { calculateCnyAmount, type CurrencyCode, type LedgerEntry, type SyncOperation } from "@cents/domain"; import { apiRequest } from "../data/api"; import { getUserDb } from "../data/db"; import { localDate } from "../data/entries"; import { createId } from "../data/ids"; import { reimbursementsForExpense, reimbursedOriginalAmount } from "../data/reimbursements"; import { useAuthStore } from "./auth"; import { useLedgerStore } from "./ledgers"; let syncPromise: Promise | null = null; let syncTriggersStarted = false; let conversionPollTimer: number | null = null; let conversionPollAttempts = 0; let conversionPollDeadline = 0; const conversionPollEntryIds = new Set(); function scheduleConversionPoll(entryIds: string[], resetAttempts = true) { for (const entryId of entryIds) conversionPollEntryIds.add(entryId); if (conversionPollTimer !== null || typeof window === "undefined") return; if (resetAttempts) { conversionPollAttempts = 0; conversionPollDeadline = Date.now() + 60_000; } const remainingMs = conversionPollDeadline - Date.now(); if (remainingMs <= 0) { conversionPollEntryIds.clear(); return; } const delayMs = Math.min(1_500 * 2 ** conversionPollAttempts, remainingMs); const poll = async () => { conversionPollTimer = null; const store = useEntryStore(); await store.syncEntries(); await store.refreshLocalEntries(); const remaining = [...conversionPollEntryIds].filter((entryId) => store.entries.some((entry) => entry.id === entryId && entry.conversionStatus === "pending"), ); conversionPollEntryIds.clear(); if (!remaining.length || Date.now() >= conversionPollDeadline) return; conversionPollAttempts += 1; scheduleConversionPoll(remaining, false); }; conversionPollTimer = window.setTimeout(() => void poll(), delayMs); } export const useEntryStore = defineStore("entries", { state: () => ({ entries: [] as LedgerEntry[], loadedForUserId: "", pendingEntryIds: [] as string[], pendingEntries: [] as Array<{ id: string; ledgerIds: string[] }>, syncing: false, syncError: "", lastSyncedAt: "" as string, }), actions: { async loadEntries() { const auth = useAuthStore(); if (this.loadedForUserId !== (auth.user?.id ?? "")) { this.entries = []; this.pendingEntryIds = []; this.pendingEntries = []; this.loadedForUserId = auth.user?.id ?? ""; } await useLedgerStore().loadLedgers(); await this.refreshLocalEntries(); this.startSyncTriggers(); void this.syncEntries(); }, async refreshLocalEntries() { const auth = useAuthStore(); if (!auth.user) { this.entries = []; this.pendingEntryIds = []; this.pendingEntries = []; this.loadedForUserId = ""; return; } const db = await getUserDb(auth.user.id); const allowedLedgerIds = new Set(useLedgerStore().ledgers.map((ledger) => ledger.id)); const canAccessEntry = (entry: LedgerEntry) => entry.ownerId === auth.user!.id || entry.ledgerIds.some((ledgerId) => allowedLedgerIds.has(ledgerId)); this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter( (entry) => entry.deletedAt === null && canAccessEntry(entry), ); const pendingOperations = (await db.syncOperations.filter((operation) => operation.syncedAt === null).toArray()) .filter((operation) => (operation.userId ?? operation.payload.updatedBy) === auth.user!.id, ); this.pendingEntryIds = [...new Set(pendingOperations.map((operation) => operation.entityId))]; this.pendingEntries = [...new Map( pendingOperations.map((operation) => [operation.entityId, { id: operation.entityId, ledgerIds: operation.ledgerIds }]), ).values()]; }, isEntryPending(entryId: string) { return this.pendingEntryIds.includes(entryId); }, pendingEntryCount(ledgerId: string) { return this.pendingEntries.filter((entry) => entry.ledgerIds.includes(ledgerId)).length; }, startSyncTriggers() { if (syncTriggersStarted || typeof window === "undefined") return; syncTriggersStarted = true; window.addEventListener("online", () => void this.syncEntries()); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") void this.syncEntries(); }); window.setInterval(() => void this.syncEntries(), 60_000); }, async syncEntries() { const auth = useAuthStore(); if (!auth.user || (typeof navigator !== "undefined" && !navigator.onLine)) { await this.refreshLocalEntries(); return; } const userId = auth.user.id; const db = await getUserDb(userId); if (syncPromise) return syncPromise; syncPromise = (async () => { this.syncing = true; this.syncError = ""; try { const conversionIds = new Set(); while (true) { const pending = (await db.syncOperations .filter((operation) => operation.syncedAt === null) .toArray()) .filter((operation) => (operation.userId ?? operation.payload.updatedBy) === userId) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .slice(0, 200); if (!pending.length) break; for (const operation of pending) { if (operation.action !== "delete" && operation.payload.currency !== "CNY" && operation.payload.conversionStatus === "pending") { conversionIds.add(operation.entityId); } } const pushed = await apiRequest<{ acceptedOperationIds: string[] }>("/api/sync/push", { method: "POST", body: { operations: pending }, }); if (!pushed.acceptedOperationIds.length) break; await db.syncOperations.bulkDelete(pushed.acceptedOperationIds); } const metadata = await db.syncMetadata.get(userId); let cursor = metadata?.cursor ?? ""; let hasMore = false; do { const path = cursor ? `/api/sync/pull?cursor=${encodeURIComponent(cursor)}` : "/api/sync/pull"; const pulled = await apiRequest<{ entries: LedgerEntry[]; cursor: string; hasMore: boolean }>(path); const remainingOperations = await db.syncOperations .filter((operation) => operation.syncedAt === null) .toArray(); const locallyChangedIds = new Set(remainingOperations.map((operation) => operation.entityId)); const safeCloudEntries = pulled.entries.filter( (entry) => !locallyChangedIds.has(entry.id) && (entry.ownerId === userId || entry.ledgerIds.length > 0), ); const inaccessibleEntryIds = pulled.entries .filter((entry) => !locallyChangedIds.has(entry.id) && entry.ownerId !== userId && entry.ledgerIds.length === 0) .map((entry) => entry.id); await db.transaction("rw", db.entries, db.syncMetadata, async () => { if (safeCloudEntries.length) await db.entries.bulkPut(safeCloudEntries); if (inaccessibleEntryIds.length) await db.entries.bulkDelete(inaccessibleEntryIds); if (pulled.cursor) { await db.syncMetadata.put({ id: userId, cursor: pulled.cursor, updatedAt: new Date().toISOString() }); } }); cursor = pulled.cursor; hasMore = pulled.hasMore; } while (hasMore); if (conversionIds.size) scheduleConversionPoll([...conversionIds]); this.lastSyncedAt = new Date().toISOString(); } catch (error) { this.syncError = error instanceof Error ? error.message : "同步失败"; } finally { await this.refreshLocalEntries(); this.syncing = false; } })(); try { await syncPromise; } finally { syncPromise = null; } }, async addEntry(entry: LedgerEntry) { const auth = useAuthStore(); if (entry.reimbursementOfEntryId) { const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId); if ( !expense || expense.deletedAt || expense.type !== "expense" || expense.ownerId !== auth.user?.id ) throw new Error("关联的原支出无效"); const sameLedgers = [...entry.ledgerIds].sort().join(",") === [...expense.ledgerIds].sort().join(","); if ( entry.type !== "income" || entry.currency !== expense.currency || entry.categoryId !== expense.categoryId || !sameLedgers ) throw new Error("报销必须继承原支出的币种、分类和账本"); if (entry.amount > expense.amount - reimbursedOriginalAmount(expense, this.entries)) { throw new Error("报销金额超过剩余可报销金额"); } } const db = await getUserDb(auth.user?.id ?? entry.ownerId); const operation: SyncOperation = { id: createId(), userId: auth.user?.id, ledgerIds: entry.ledgerIds, entity: "entry", entityId: entry.id, action: "create", payload: entry, createdAt: new Date().toISOString(), syncedAt: null, }; await db.transaction("rw", db.entries, db.syncOperations, async () => { await db.entries.put(entry); await db.syncOperations.put(operation); }); await this.refreshLocalEntries(); void this.resolveLocalConversion(entry.id); void this.syncEntries(); }, async updateEntry(entry: LedgerEntry) { const auth = useAuthStore(); const db = await getUserDb(auth.user?.id ?? entry.ownerId); const existing = await db.entries.get(entry.id); if (!existing || existing.deletedAt) throw new Error("Entry not found"); if (existing.reimbursementOfEntryId !== entry.reimbursementOfEntryId) { throw new Error("不能修改报销关联"); } if (entry.reimbursementOfEntryId) { const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId); if (!expense || expense.deletedAt || expense.ownerId !== auth.user?.id) { throw new Error("关联的原支出无效"); } const otherReimbursements = reimbursementsForExpense(this.entries, expense.id) .filter((item) => item.id !== entry.id) .reduce((sum, item) => sum + item.amount, 0); if ( entry.type !== "income" || entry.currency !== expense.currency || entry.categoryId !== expense.categoryId || entry.amount + otherReimbursements > expense.amount ) throw new Error("报销金额超过剩余可报销金额"); } else if (existing.type === "expense") { const reimbursed = reimbursedOriginalAmount(existing, this.entries); const sameLedgers = [...existing.ledgerIds].sort().join(",") === [...entry.ledgerIds].sort().join(","); if ( reimbursed > entry.amount || (reimbursed > 0 && ( existing.currency !== entry.currency || existing.categoryId !== entry.categoryId || !sameLedgers )) ) { throw new Error("存在报销时不能缩小支出金额、改变分类、币种或账本"); } } const exchangeRateDate = localDate(entry.occurredAt); const conversionChanged = existing.amount !== entry.amount || existing.currency !== entry.currency || existing.exchangeRateDate !== exchangeRateDate; const updated: LedgerEntry = { ...existing, ...entry, ledgerIds: [...new Set(entry.ledgerIds)], baseCurrency: "CNY", baseAmount: conversionChanged ? (entry.currency === "CNY" ? entry.amount : null) : existing.baseAmount, exchangeRate: conversionChanged ? (entry.currency === "CNY" ? "1" : null) : existing.exchangeRate, exchangeRateDate, exchangeRateEffectiveDate: conversionChanged ? (entry.currency === "CNY" ? exchangeRateDate : null) : existing.exchangeRateEffectiveDate, exchangeRateSource: conversionChanged ? "system" : existing.exchangeRateSource, conversionStatus: conversionChanged ? (entry.currency === "CNY" ? "exact" : "pending") : existing.conversionStatus, updatedAt: new Date().toISOString(), updatedBy: auth.user?.id ?? existing.updatedBy, version: existing.version + 1, }; const operation: SyncOperation = { id: createId(), userId: auth.user?.id, ledgerIds: updated.ledgerIds, entity: "entry", entityId: updated.id, action: "update", payload: updated, createdAt: updated.updatedAt, syncedAt: null, }; await db.transaction("rw", db.entries, db.syncOperations, async () => { await db.entries.put(updated); await db.syncOperations.put(operation); }); await this.refreshLocalEntries(); if (conversionChanged) void this.resolveLocalConversion(updated.id); void this.syncEntries(); return updated; }, async resolveLocalConversion(entryId: string) { const auth = useAuthStore(); if (!auth.user) return; const db = await getUserDb(auth.user.id); const entry = await db.entries.get(entryId); if (!entry || entry.deletedAt || entry.currency === "CNY") return; const cacheId = `${entry.currency}:${entry.exchangeRateDate}`; let cached = await db.exchangeRates.get(cacheId); if (!cached && (typeof navigator === "undefined" || navigator.onLine)) { try { const result = await apiRequest<{ rate: { currency: CurrencyCode; requestedDate: string; effectiveDate: string | null; cnyPerUnit: string | null; status: "exact" | "fallback" | "pending"; }; }>(`/api/exchange-rates/${entry.currency}/${entry.exchangeRateDate}`); if (result.rate.cnyPerUnit && result.rate.effectiveDate && result.rate.status !== "pending") { cached = { id: cacheId, currency: result.rate.currency, requestedDate: result.rate.requestedDate, effectiveDate: result.rate.effectiveDate, cnyPerUnit: result.rate.cnyPerUnit, status: result.rate.status, updatedAt: new Date().toISOString(), }; await db.exchangeRates.put(cached); } } catch { // The original-currency entry is already durable; conversion can retry after sync. } } if (!cached) { const candidates = await db.exchangeRates.where("currency").equals(entry.currency).toArray(); cached = candidates .sort((left, right) => { const leftFuture = left.requestedDate > entry.exchangeRateDate; const rightFuture = right.requestedDate > entry.exchangeRateDate; if (leftFuture !== rightFuture) return leftFuture ? 1 : -1; return Math.abs(Date.parse(left.requestedDate) - Date.parse(entry.exchangeRateDate)) - Math.abs(Date.parse(right.requestedDate) - Date.parse(entry.exchangeRateDate)); })[0]; } if (!cached) return; const current = await db.entries.get(entryId); if ( !current || current.deletedAt || current.amount !== entry.amount || current.currency !== entry.currency || current.exchangeRateDate !== entry.exchangeRateDate ) return; const baseAmount = calculateCnyAmount(current.amount, current.currency, cached.cnyPerUnit); await db.entries.update(entryId, { baseAmount, exchangeRate: cached.cnyPerUnit, exchangeRateEffectiveDate: cached.effectiveDate, exchangeRateSource: "system", conversionStatus: cached.requestedDate === current.exchangeRateDate && cached.status === "exact" ? "exact" : "fallback", }); await this.refreshLocalEntries(); }, async deleteEntry(entryId: string) { const auth = useAuthStore(); if (!auth.user) return; const db = await getUserDb(auth.user.id); const existing = await db.entries.get(entryId); if (!existing || existing.deletedAt) return; if (existing.type === "expense" && reimbursementsForExpense(this.entries, entryId).length) { throw new Error("存在报销记录,不能删除原支出"); } const deletedAt = new Date().toISOString(); const deleted: LedgerEntry = { ...existing, deletedAt, updatedAt: deletedAt, updatedBy: auth.user?.id ?? existing.updatedBy, version: existing.version + 1, }; const operation: SyncOperation = { id: createId(), userId: auth.user?.id, ledgerIds: deleted.ledgerIds, entity: "entry", entityId: deleted.id, action: "delete", payload: deleted, createdAt: deletedAt, syncedAt: null, }; await db.transaction("rw", db.entries, db.syncOperations, async () => { await db.entries.put(deleted); await db.syncOperations.put(operation); }); await this.refreshLocalEntries(); void this.syncEntries(); }, }, });