feat: add asynchronous multi-currency conversion

This commit is contained in:
openclaw 2026-07-25 00:55:00 +08:00
parent d3723b338b
commit 63c6cec81b
20 changed files with 996 additions and 58 deletions

View File

@ -19,3 +19,5 @@ TEST_MODE=false
POSTGRES_DB=cents
POSTGRES_USER=cents
POSTGRES_PASSWORD=change-me
APP_TIME_ZONE=Asia/Shanghai
EXCHANGE_RATE_API_URL=https://api.frankfurter.dev

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { currencies, type CurrencyCode } from "@cents/domain";
import { Check, X } from "@lucide/vue";
defineProps<{
open: boolean;
modelValue: CurrencyCode;
}>();
const emit = defineEmits<{
"update:modelValue": [currency: CurrencyCode];
close: [];
}>();
const commonCurrencies: CurrencyCode[] = ["CNY", "USD", "EUR", "JPY", "THB"];
const otherCurrencies: CurrencyCode[] = ["HKD"];
function choose(currency: CurrencyCode) {
emit("update:modelValue", currency);
emit("close");
}
</script>
<template>
<div v-if="open" class="currency-picker-layer">
<button class="currency-picker-scrim" type="button" aria-label="关闭币种选择" @click="emit('close')"></button>
<section class="currency-picker-sheet" role="dialog" aria-modal="true" aria-label="选择币种">
<div class="currency-picker-handle"></div>
<header>
<div><strong>选择币种</strong><span>记账金额所使用的币种</span></div>
<button type="button" aria-label="关闭" title="关闭" @click="emit('close')"><X :size="19" /></button>
</header>
<div class="currency-list">
<button
v-for="code in commonCurrencies"
:key="code"
type="button"
:class="{ selected: modelValue === code }"
@click="choose(code)"
>
<span>{{ currencies[code].symbol }}</span>
<strong>{{ currencies[code].name }}<small>{{ code }}</small></strong>
<Check v-if="modelValue === code" :size="19" />
</button>
</div>
<p>更多币种</p>
<div class="currency-list compact">
<button
v-for="code in otherCurrencies"
:key="code"
type="button"
:class="{ selected: modelValue === code }"
@click="choose(code)"
>
<span>{{ currencies[code].symbol }}</span>
<strong>{{ currencies[code].name }}<small>{{ code }}</small></strong>
<Check v-if="modelValue === code" :size="19" />
</button>
</div>
</section>
</div>
</template>
<style scoped>
.currency-picker-layer { position:absolute; inset:0; z-index:42; }
.currency-picker-scrim { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(18,35,32,.42); backdrop-filter:blur(2px); }
.currency-picker-sheet { position:absolute; right:0; bottom:0; left:0; max-height:90%; overflow-y:auto; border-radius:14px 14px 0 0; padding:0 16px calc(16px + env(safe-area-inset-bottom)); background:#fff; color:#26342f; box-shadow:0 -16px 36px rgba(22,45,40,.2); }
.currency-picker-handle { width:38px; height:4px; margin:8px auto 3px; border-radius:2px; background:#b9cac5; }
.currency-picker-sheet > header { min-height:58px; display:flex; align-items:center; justify-content:space-between; }
.currency-picker-sheet > header > div { display:grid; gap:1px; }
.currency-picker-sheet > header strong { font-size:16px; }
.currency-picker-sheet > header span,.currency-picker-sheet > p { color:#7b8884; font-size:11px; }
.currency-picker-sheet > header > button { width:36px; height:36px; display:grid; place-items:center; border:0; border-radius:8px; background:#f0f5f3; color:#536762; }
.currency-picker-sheet > p { margin:13px 2px 7px; }
.currency-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.currency-list.compact { grid-template-columns:1fr; }
.currency-list > button { min-width:0; height:58px; display:grid; grid-template-columns:38px minmax(0,1fr) 20px; align-items:center; gap:9px; border:1px solid #dce7e4; border-radius:8px; padding:7px 10px; background:#fff; color:#344640; text-align:left; }
.currency-list > button.selected { border-color:#087f72; background:#f0f8f6; color:#087f72; }
.currency-list > button > span { width:36px; height:36px; display:grid; place-items:center; border-radius:50%; background:#edf3f1; color:#344640; font-size:15px; font-weight:750; }
.currency-list > button > strong { min-width:0; display:grid; gap:1px; font-size:13px; }
.currency-list small { color:#82908c; font-size:10px; font-weight:600; }
@media (max-width:350px) { .currency-list { grid-template-columns:1fr; } }
</style>

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import type { EntryType } from "@cents/domain";
import { currencies, currencyMinorUnits, type CurrencyCode, type EntryType } from "@cents/domain";
import { BookOpen, CalendarDays, Check, ChevronDown, Delete as BackspaceIcon, X } from "@lucide/vue";
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { entryTypes, type Category } from "../data/categories";
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
import CurrencyPicker from "./CurrencyPicker.vue";
type SavedEntry = {
type: EntryType;
@ -12,12 +13,14 @@ type SavedEntry = {
note: string;
occurredAt: string;
ledgerIds: string[];
currency: CurrencyCode;
};
const props = defineProps<{
ledgerId: string;
ledgerName: string;
personalLedgerId: string;
defaultCurrency: CurrencyCode;
ledgerOptions: Array<{ id: string; name: string; gradient: string; isPersonal: boolean }>;
}>();
@ -27,6 +30,7 @@ const emit = defineEmits<{
}>();
const expression = ref("0");
const currency = ref<CurrencyCode>(props.defaultCurrency);
const entryType = ref<EntryType>("expense");
const categoryPath = ref<Category[]>([]);
const note = ref("");
@ -35,6 +39,7 @@ const dateInput = ref<HTMLInputElement | null>(null);
const selectorOpen = ref(false);
const noteEditing = ref(false);
const ledgerPickerOpen = ref(false);
const currencyPickerOpen = ref(false);
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
@ -44,14 +49,16 @@ const categoryPathLabel = computed(() =>
);
const categoryDisplayLabel = computed(() => selectedCategory.value?.label ?? typeOption.value.label);
const calculatedAmount = computed(() => evaluateExpression(expression.value));
const minorUnits = computed(() => currencyMinorUnits(currency.value));
const hasCalculation = computed(() => /[+-]/.test(expression.value));
const calculationLabel = computed(() => expression.value.replaceAll("-", "").replaceAll("+", " + ").replaceAll("", " "));
const displayAmount = computed(() =>
new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
minimumFractionDigits: minorUnits.value,
maximumFractionDigits: minorUnits.value,
}).format(calculatedAmount.value),
);
const currencySymbol = computed(() => currencies[currency.value].symbol);
const selectedLedgerLabel = computed(() => {
const visibleNames = props.ledgerOptions
.filter((ledger) => !ledger.isPersonal && selectedLedgerIds.value.includes(ledger.id))
@ -71,10 +78,16 @@ const dateLabel = computed(() => {
return `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
});
const canSave = computed(() => {
const amount = Math.round(calculatedAmount.value * 100);
const amount = Math.round(calculatedAmount.value * 10 ** minorUnits.value);
return Number.isFinite(amount) && amount > 0 && occurredAtValid.value;
});
watch(currency, () => {
if (minorUnits.value === 0 && expression.value.includes(".")) {
expression.value = String(Math.round(calculatedAmount.value));
}
});
function toLocalDateTime(date: Date) {
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
@ -106,7 +119,8 @@ function evaluateExpression(value: string) {
if (operand === "" || operand === ".") return;
const right = Number(operand);
result = operator === "+" ? result + right : result - right;
result = Math.round(result * 100) / 100;
const factor = 10 ** minorUnits.value;
result = Math.round(result * factor) / factor;
});
return result;
@ -119,7 +133,7 @@ function currentOperand() {
function appendDigit(digit: string) {
const operand = currentOperand();
const decimalPlaces = operand.includes(".") ? operand.split(".")[1].length : 0;
if (decimalPlaces >= 2 || operand.replace(".", "").length >= 9) return;
if ((minorUnits.value > 0 && decimalPlaces >= minorUnits.value) || operand.replace(".", "").length >= 9) return;
if (expression.value === "0") expression.value = digit;
else if (operand === "0") expression.value = `${expression.value.slice(0, -1)}${digit}`;
@ -127,6 +141,7 @@ function appendDigit(digit: string) {
}
function appendDecimal() {
if (minorUnits.value === 0) return;
const operand = currentOperand();
if (operand.includes(".")) return;
expression.value += operand === "" ? "0." : ".";
@ -170,6 +185,12 @@ function openLedgerPicker() {
ledgerPickerOpen.value = true;
}
function openCurrencyPicker() {
confirmCategory();
noteEditing.value = false;
currencyPickerOpen.value = true;
}
function toggleLedger(ledgerId: string) {
if (ledgerId === props.personalLedgerId) return;
if (selectedLedgerIds.value.includes(ledgerId)) {
@ -182,7 +203,7 @@ function toggleLedger(ledgerId: string) {
}
function saveEntry() {
const amount = Math.round(calculatedAmount.value * 100);
const amount = Math.round(calculatedAmount.value * 10 ** minorUnits.value);
if (!canSave.value) return;
emit("save", {
@ -192,6 +213,7 @@ function saveEntry() {
note: note.value.trim(),
occurredAt: new Date(occurredAt.value).toISOString(),
ledgerIds: selectedLedgerIds.value,
currency: currency.value,
});
}
</script>
@ -205,7 +227,9 @@ function saveEntry() {
<header class="drawer-header">
<div class="amount-block">
<div class="amount-result">
<span class="amount-prefix">¥</span>
<button class="amount-prefix" type="button" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="openCurrencyPicker">
{{ currencySymbol }}
</button>
<strong class="amount-value">{{ displayAmount }}</strong>
</div>
<span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span>
@ -306,4 +330,6 @@ function saveEntry() {
<button class="quick-ledger-picker-done" type="button" @click="ledgerPickerOpen = false"><Check :size="18" />完成</button>
</section>
</div>
<CurrencyPicker v-model="currency" :open="currencyPickerOpen" @close="currencyPickerOpen = false" />
</template>

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import type { CurrencyCode } from "@cents/domain";
import { BarChart3, BookOpen, Check, CircleUserRound, LibraryBig, Plus } from "@lucide/vue";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
@ -31,12 +32,12 @@ async function saveEntry(input: {
note: string;
occurredAt: string;
ledgerIds: string[];
currency: CurrencyCode;
}) {
const ledger = ledgerStore.currentLedger;
if (!ledger) return;
await entryStore.addEntry(createEntry({
...input,
currency: ledger.defaultCurrency,
createdBy: authStore.user?.id,
}));
drawerOpen.value = false;
@ -65,6 +66,7 @@ async function saveEntry(input: {
:ledger-name="ledgerStore.currentLedger.name"
:ledger-options="ledgerOptions"
:personal-ledger-id="ledgerStore.personalLedger?.id ?? ''"
:default-currency="ledgerStore.currentLedger.defaultCurrency"
@close="drawerOpen = false"
@save="saveEntry"
/>

View File

@ -1,5 +1,5 @@
import Dexie, { type EntityTable } from "dexie";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import type { ConversionStatus, CurrencyCode, LedgerEntry, SyncOperation } from "@cents/domain";
import type { LedgerRecord, UserPreference } from "./ledgers";
export type SyncMetadata = {
@ -8,12 +8,23 @@ export type SyncMetadata = {
updatedAt: string;
};
export type LocalExchangeRate = {
id: string;
currency: CurrencyCode;
requestedDate: string;
effectiveDate: string;
cnyPerUnit: string;
status: Exclude<ConversionStatus, "pending">;
updatedAt: string;
};
export const db = new Dexie("cents") as Dexie & {
entries: EntityTable<LedgerEntry, "id">;
syncOperations: EntityTable<SyncOperation, "id">;
ledgers: EntityTable<LedgerRecord, "id">;
userPreferences: EntityTable<UserPreference, "id">;
syncMetadata: EntityTable<SyncMetadata, "id">;
exchangeRates: EntityTable<LocalExchangeRate, "id">;
};
db.version(1).stores({
@ -63,3 +74,42 @@ db.version(4).stores({
delete operation.ledgerId;
});
});
db.version(5).stores({
entries: "id, ownerId, *ledgerIds, occurredAt, updatedAt, deletedAt, conversionStatus",
syncOperations: "id, *ledgerIds, createdAt, syncedAt",
ledgers: "id, updatedAt, archivedAt",
userPreferences: "id, userId, key, updatedAt",
syncMetadata: "id, updatedAt",
exchangeRates: "id, currency, requestedDate, updatedAt",
}).upgrade(async (transaction) => {
const normalizeEntry = (entry: LegacyEntry) => {
const date = localDate(entry.occurredAt);
entry.exchangeRateDate ??= date;
if (entry.currency === "CNY") {
entry.baseCurrency = "CNY";
entry.baseAmount = entry.amount;
entry.exchangeRate = "1";
entry.exchangeRateEffectiveDate = date;
entry.exchangeRateSource = "system";
entry.conversionStatus = "exact";
} else if (!entry.conversionStatus || (entry.exchangeRate === "1" && entry.exchangeRateSource === "manual")) {
entry.baseCurrency = "CNY";
entry.baseAmount = null;
entry.exchangeRate = null;
entry.exchangeRateEffectiveDate = null;
entry.exchangeRateSource = "system";
entry.conversionStatus = "pending";
}
};
await transaction.table<LegacyEntry>("entries").toCollection().modify(normalizeEntry);
await transaction.table<LegacyOperation>("syncOperations").toCollection().modify((operation) => {
normalizeEntry(operation.payload);
});
});
function localDate(value: string) {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}

View File

@ -16,6 +16,8 @@ const baseCurrency: CurrencyCode = "CNY";
export function createEntry(input: EntryInput): LedgerEntry {
const now = new Date().toISOString();
const exchangeRateDate = localDate(input.occurredAt);
const isCny = input.currency === "CNY";
return {
id: createId(),
@ -25,9 +27,12 @@ export function createEntry(input: EntryInput): LedgerEntry {
amount: input.amount,
currency: input.currency,
baseCurrency,
baseAmount: input.amount,
exchangeRate: "1",
exchangeRateSource: "manual",
baseAmount: isCny ? input.amount : null,
exchangeRate: isCny ? "1" : null,
exchangeRateDate,
exchangeRateEffectiveDate: isCny ? exchangeRateDate : null,
exchangeRateSource: "system",
conversionStatus: isCny ? "exact" : "pending",
categoryId: input.categoryId,
note: input.note,
occurredAt: input.occurredAt,
@ -40,6 +45,12 @@ export function createEntry(input: EntryInput): LedgerEntry {
};
}
export function localDate(value: string) {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function createDemoEntries(): LedgerEntry[] {
const now = new Date();
const at = (daysAgo: number, hours: number, minutes: number) => {

View File

@ -1,7 +1,8 @@
import { defineStore } from "pinia";
import type { LedgerEntry, SyncOperation } from "@cents/domain";
import { calculateCnyAmount, type CurrencyCode, type LedgerEntry, type SyncOperation } from "@cents/domain";
import { apiRequest } from "../data/api";
import { db } from "../data/db";
import { localDate } from "../data/entries";
import { createId } from "../data/ids";
import { useAuthStore } from "./auth";
@ -137,6 +138,7 @@ export const useEntryStore = defineStore("entries", {
});
await this.refreshLocalEntries();
void this.resolveLocalConversion(entry.id);
void this.syncEntries();
},
async updateEntry(entry: LedgerEntry) {
@ -144,10 +146,25 @@ export const useEntryStore = defineStore("entries", {
const existing = await db.entries.get(entry.id);
if (!existing || existing.deletedAt) throw new Error("Entry not found");
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,
@ -170,9 +187,75 @@ export const useEntryStore = defineStore("entries", {
});
await this.refreshLocalEntries();
if (conversionChanged) void this.resolveLocalConversion(updated.id);
void this.syncEntries();
return updated;
},
async resolveLocalConversion(entryId: string) {
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();
const existing = await db.entries.get(entryId);

View File

@ -744,11 +744,20 @@ input:focus-visible {
}
.amount-prefix {
min-width: 30px;
border: 0;
border-bottom: 1px dashed #9baba6;
padding: 0 2px 1px;
background: transparent;
color: #70807c;
font-size: 20px;
font-weight: 650;
line-height: 1.1;
}
.conversion-notice { min-height:38px; display:flex; align-items:center; border-bottom:1px solid #eadfca; padding:7px 12px; background:#fff9ed; color:#775b24; font-size:12px; }
.conversion-notice span { display:flex; align-items:center; gap:6px; }
.amount-value {
overflow: hidden;
color: #172421;

View File

@ -1,5 +1,11 @@
<script setup lang="ts">
import type { EntryType, LedgerEntry } from "@cents/domain";
import {
currencies,
currencyMinorUnits,
type CurrencyCode,
type EntryType,
type LedgerEntry,
} from "@cents/domain";
import {
ArrowLeft,
BookOpen,
@ -25,6 +31,7 @@ import {
import { ledgerTheme } from "../data/ledgers";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
import CurrencyPicker from "../components/CurrencyPicker.vue";
type CategorySheetMode = "parent" | "child";
@ -40,8 +47,10 @@ const errorMessage = ref("");
const categorySheetMode = ref<CategorySheetMode | null>(null);
const ledgerPickerOpen = ref(false);
const dateInput = ref<HTMLInputElement | null>(null);
const currencyPickerOpen = ref(false);
const amount = ref("");
const currency = ref<CurrencyCode>("CNY");
const ledgerIds = ref<string[]>([]);
const parentCategoryId = ref("");
const childCategoryId = ref("");
@ -92,6 +101,13 @@ const createdAtLabel = computed(() => {
hour12: false,
}).format(new Date(entry.value.createdAt));
});
const currencySymbol = computed(() => currencies[currency.value].symbol);
const conversionLabel = computed(() => {
if (!entry.value || entry.value.currency === "CNY") return "";
if (entry.value.baseAmount === null) return "人民币金额等待后台换算";
const rateDate = entry.value.exchangeRateEffectiveDate ?? entry.value.exchangeRateDate;
return `约 ¥${(entry.value.baseAmount / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} · ${rateDate}${entry.value.conversionStatus === "fallback" ? " 最近汇率" : ""}`;
});
onMounted(async () => {
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
@ -102,7 +118,9 @@ onMounted(async () => {
function hydrate(value: LedgerEntry) {
entry.value = value;
amount.value = (value.amount / 100).toFixed(2);
currency.value = value.currency;
const minorUnits = currencyMinorUnits(value.currency);
amount.value = (value.amount / 10 ** minorUnits).toFixed(minorUnits);
ledgerIds.value = [...new Set([...value.ledgerIds, ledgerStore.personalLedger?.id].filter((id): id is string => Boolean(id)))];
note.value = value.note;
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
@ -166,12 +184,11 @@ async function saveEntry() {
saving.value = true;
errorMessage.value = "";
try {
const cents = Math.round(numericAmount * 100);
const exchangeRate = Number(entry.value.exchangeRate) || 1;
const minorAmount = Math.round(numericAmount * 10 ** currencyMinorUnits(currency.value));
const updated = await store.updateEntry({
...entry.value,
amount: cents,
baseAmount: Math.round(cents * exchangeRate),
amount: minorAmount,
currency: currency.value,
ledgerIds: [...ledgerIds.value],
categoryId: childCategoryId.value || parentCategoryId.value || entryType.value,
note: note.value.trim(),
@ -228,9 +245,12 @@ async function deleteEntry() {
</div>
<span class="entry-type-label">{{ typeOption.label }}</span>
<label class="entry-amount-input">
<span>¥</span>
<button type="button" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="currencyPickerOpen = true">
{{ currencySymbol }}
</button>
<input v-model="amount" inputmode="decimal" aria-label="金额" />
</label>
<span v-if="conversionLabel" class="entry-conversion-label">{{ conversionLabel }}</span>
</section>
<section class="entry-edit-fields" aria-label="编辑条目">
@ -361,6 +381,8 @@ async function deleteEntry() {
</div>
</Transition>
<CurrencyPicker v-model="currency" :open="currencyPickerOpen" @close="currencyPickerOpen = false" />
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
<section class="quick-ledger-picker" aria-label="选择关联账本">
@ -470,7 +492,13 @@ async function deleteEntry() {
color: #0d8b67;
}
.entry-amount-input > span {
.entry-amount-input > button {
min-width: 34px;
border: 0;
border-bottom: 1px dashed currentColor;
padding: 0 2px 1px;
background: transparent;
color: inherit;
font-size: 23px;
font-weight: 700;
}
@ -488,6 +516,12 @@ async function deleteEntry() {
text-align: center;
}
.entry-conversion-label {
min-height: 16px;
color: #7b8884;
font-size: 11px;
}
.entry-edit-fields {
margin-top: 12px;
border-top: 1px solid #dce7e4;

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { LedgerThemeId } from "@cents/domain";
import type { CurrencyCode, LedgerThemeId } from "@cents/domain";
import { ArrowLeft, Check } from "@lucide/vue";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
@ -10,7 +10,7 @@ const router = useRouter();
const ledgerStore = useLedgerStore();
const name = ref("");
const themeId = ref<LedgerThemeId>("jade");
const currency = ref<"CNY" | "USD" | "EUR" | "JPY" | "HKD">("CNY");
const currency = ref<CurrencyCode>("CNY");
const saving = ref(false);
onMounted(async () => {
@ -44,7 +44,7 @@ async function save() {
<form class="settings-form" @submit.prevent="save">
<section>
<label><span>账本名称</span><input v-model="name" maxlength="24" :disabled="ledgerStore.currentLedger?.isPersonal" required /></label>
<label><span>默认币种</span><select v-model="currency"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="HKD">港币 HKD</option></select></label>
<label><span>默认币种</span><select v-model="currency"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="THB">泰铢 THB</option><option value="HKD">港币 HKD</option></select></label>
</section>
<section class="color-setting">
<span>账本主题</span>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { LedgerEntry } from "@cents/domain";
import { formatCurrencyAmount, type LedgerEntry } from "@cents/domain";
import {
BookOpen,
ChevronDown,
@ -7,6 +7,7 @@ import {
CloudOff,
Copy,
LoaderCircle,
Clock3,
MoreHorizontal,
RefreshCw,
Search,
@ -59,6 +60,7 @@ const monthlyEntries = computed(() => {
const totals = computed(() => {
return monthlyEntries.value.reduce(
(result, entry) => {
if (entry.baseAmount === null) return result;
if (entry.type === "income") result.income += entry.baseAmount;
else result.expense += entry.baseAmount;
return result;
@ -73,6 +75,9 @@ const expenseText = computed(() => formatMoney(totals.value.expense));
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
const pendingEntryCount = computed(() => store.pendingEntryCount(ledgerStore.currentLedgerId));
const pendingConversionCount = computed(() =>
monthlyEntries.value.filter((entry) => entry.conversionStatus === "pending").length,
);
const currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
const dailyTotals = computed(() => {
@ -80,6 +85,7 @@ const dailyTotals = computed(() => {
for (const entry of monthlyEntries.value) {
const key = localDateKey(entry.occurredAt);
const summary = summaries.get(key) ?? { income: 0, expense: 0 };
if (entry.baseAmount === null) continue;
if (entry.type === "income") summary.income += entry.baseAmount;
else summary.expense += entry.baseAmount;
summaries.set(key, summary);
@ -310,6 +316,9 @@ async function shareLedger() {
<RefreshCw :size="15" :class="{ spinning: store.syncing }" />{{ store.syncing ? "同步中" : "立即同步" }}
</button>
</div>
<div v-if="pendingConversionCount" class="conversion-notice" role="status">
<span><Clock3 :size="15" />{{ pendingConversionCount }} 条外币账目等待换算</span>
</div>
<div class="list-toolbar">
<div class="list-toolbar-copy">
<div class="list-toolbar-heading">
@ -358,9 +367,13 @@ async function shareLedger() {
</div>
<div class="entry-value">
<strong :class="entry.type">
{{ entry.type === "expense" ? "" : "+" }}{{ formatMoney(entry.baseAmount) }}
{{ entry.type === "expense" ? "" : "+" }}{{ entry.baseAmount === null
? formatCurrencyAmount(entry.amount, entry.currency)
: `¥${formatMoney(entry.baseAmount)}` }}
</strong>
<span>{{ formatTime(entry.occurredAt) }}</span>
<span>{{ entry.currency === "CNY" || entry.baseAmount === null
? formatTime(entry.occurredAt)
: formatCurrencyAmount(entry.amount, entry.currency) }}</span>
</div>
</RouterLink>
</section>

View File

@ -98,7 +98,7 @@ async function createLedger() {
<button type="button" aria-label="关闭" title="关闭" @click="closeCreate"><X :size="20" /></button>
</header>
<label class="create-ledger-name"><span>账本名称</span><input v-model="name" maxlength="40" placeholder="例如:旅行计划" autocomplete="off" autofocus required /></label>
<label class="create-ledger-currency"><span>默认币种</span><select v-model="currency"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="HKD">港币 HKD</option></select></label>
<label class="create-ledger-currency"><span>默认币种</span><select v-model="currency"><option value="CNY">人民币 CNY</option><option value="USD">美元 USD</option><option value="EUR">欧元 EUR</option><option value="JPY">日元 JPY</option><option value="THB">泰铢 THB</option><option value="HKD">港币 HKD</option></select></label>
<fieldset class="create-ledger-colors">
<legend>账本主题</legend>
<div><button v-for="theme in ledgerThemes" :key="theme.id" type="button" :class="{ active: themeId === theme.id }" :style="{ background: theme.gradient }" :aria-label="`选择${theme.name}主题`" :title="theme.name" @click="themeId = theme.id"><Check v-if="themeId === theme.id" :size="17" /></button></div>

View File

@ -71,10 +71,12 @@ const entries = computed(() => {
});
const totals = computed(() => summarize(entries.value));
const convertedEntries = computed(() => entries.value.filter((entry) => entry.baseAmount !== null));
const pendingConversionCount = computed(() => entries.value.length - convertedEntries.value.length);
const dailyStats = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of entries.value) {
for (const entry of convertedEntries.value) {
const date = new Date(entry.occurredAt);
const key = toDateInput(date);
groups.set(key, [...(groups.get(key) ?? []), entry]);
@ -89,9 +91,9 @@ const maxDailyAmount = computed(() =>
const categoryStats = computed(() => {
const groups = new Map<string, { amount: number; count: number }>();
for (const entry of entries.value) {
for (const entry of convertedEntries.value) {
const current = groups.get(entry.categoryId) ?? { amount: 0, count: 0 };
current.amount += entry.baseAmount;
current.amount += entry.baseAmount!;
current.count += 1;
groups.set(entry.categoryId, current);
}
@ -106,7 +108,7 @@ const maxCategoryAmount = computed(() => Math.max(1, ...categoryStats.value.map(
const memberStats = computed(() => {
const groups = new Map<string, LedgerEntry[]>();
for (const entry of entries.value) groups.set(entry.createdBy, [...(groups.get(entry.createdBy) ?? []), entry]);
for (const entry of convertedEntries.value) groups.set(entry.createdBy, [...(groups.get(entry.createdBy) ?? []), entry]);
return Array.from(groups, ([userId, items]) => ({
userId,
name: ledgerStore.memberName(userId),
@ -122,6 +124,7 @@ onMounted(async () => {
function summarize(items: LedgerEntry[]) {
return items.reduce(
(result, entry) => {
if (entry.baseAmount === null) return result;
if (entry.type === "income") result.income += entry.baseAmount;
else result.expense += entry.baseAmount;
return result;
@ -193,6 +196,9 @@ function dailyLabel(value: string) {
<div class="income"><span><TrendingUp :size="17" />收入</span><strong>{{ money(totals.income) }}</strong></div>
<div class="expense"><span><TrendingDown :size="17" />支出</span><strong>{{ money(totals.expense) }}</strong></div>
</section>
<p v-if="pendingConversionCount" class="stats-conversion-notice">
{{ pendingConversionCount }} 条外币账目等待换算暂未计入人民币统计
</p>
<div class="stats-tabs" role="tablist" aria-label="统计维度">
<button type="button" :class="{ active: activeTab === 'flow' }" @click="activeTab = 'flow'">收支</button>
@ -269,6 +275,7 @@ function dailyLabel(value: string) {
.stats-summary .balance strong { font-size:24px; }
.stats-summary .income { color: #0d8b67; }
.stats-summary .expense { color: #d84c36; }
.stats-conversion-notice { margin:8px 0 0; border-left:3px solid #d69a32; padding:7px 9px; background:#fff8e9; color:#755719; font-size:11px; }
.stats-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin: 14px 0 10px; border-radius: 8px; padding: 4px; background: #e7eeec; }
.stats-tabs button { height: 38px; border: 0; border-radius: 6px; background: transparent; color: #71807c; font-size: 13px; font-weight: 680; }
.stats-tabs button.active { background: #fff; color: #087f72; box-shadow: 0 2px 7px rgba(28,52,47,.12); }

View File

@ -25,6 +25,8 @@ services:
DATABASE_URL: postgres://${POSTGRES_USER:-cents}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-cents}
PUBLIC_ORIGIN: https://${DOMAIN}
COOKIE_SECURE: "true"
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Shanghai}
EXCHANGE_RATE_API_URL: ${EXCHANGE_RATE_API_URL:-https://api.frankfurter.dev}
PORT: "3000"
depends_on:
db:

View File

@ -2,7 +2,7 @@
## 状态
- 设计状态:待实现
- 设计状态:首版已实现,后续按本文验收项继续补齐测试和运营能力
- 记账本位币固定为 `CNY`
- 本文描述多币种记账、汇率缓存、离线降级及人民币汇总的首版方案。

View File

@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { calculateCnyAmount, currencyMinorUnits } from "./index.js";
test("currency metadata uses zero minor units for JPY", () => {
assert.equal(currencyMinorUnits("JPY"), 0);
assert.equal(currencyMinorUnits("THB"), 2);
});
test("currency conversion rounds to the nearest CNY fen", () => {
assert.equal(calculateCnyAmount(12345, "THB", "0.201010000000"), 2481);
assert.equal(calculateCnyAmount(2500, "USD", "6.773000000000"), 16933);
assert.equal(calculateCnyAmount(1234, "JPY", "0.0473"), 5837);
});

View File

@ -1,8 +1,55 @@
export type EntryType = "expense" | "income";
export type CurrencyCode = "CNY" | "USD" | "EUR" | "JPY" | "HKD";
export const currencies = {
CNY: { name: "人民币", symbol: "¥", minorUnits: 2 },
USD: { name: "美元", symbol: "$", minorUnits: 2 },
EUR: { name: "欧元", symbol: "€", minorUnits: 2 },
JPY: { name: "日元", symbol: "¥", minorUnits: 0 },
THB: { name: "泰铢", symbol: "฿", minorUnits: 2 },
HKD: { name: "港币", symbol: "HK$", minorUnits: 2 },
} as const;
export type CurrencyCode = keyof typeof currencies;
export type ExchangeRateSource = "manual" | "system";
export type ConversionStatus = "exact" | "fallback" | "pending";
export function currencyMinorUnits(currency: CurrencyCode) {
return currencies[currency].minorUnits;
}
export function formatCurrencyAmount(amount: number, currency: CurrencyCode, includeSymbol = true) {
const minorUnits = currencyMinorUnits(currency);
return new Intl.NumberFormat("zh-CN", {
style: includeSymbol ? "currency" : "decimal",
currency: includeSymbol ? currency : undefined,
currencyDisplay: "narrowSymbol",
minimumFractionDigits: minorUnits,
maximumFractionDigits: minorUnits,
}).format(amount / 10 ** minorUnits);
}
function decimalParts(value: string) {
if (!/^\d+(?:\.\d+)?$/.test(value)) throw new Error("Invalid decimal rate");
const [integer, fraction = ""] = value.split(".");
return {
scaled: BigInt(`${integer}${fraction}`),
scale: 10n ** BigInt(fraction.length),
};
}
export function calculateCnyAmount(amount: number, currency: CurrencyCode, cnyPerUnit: string) {
if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error("Invalid amount");
const { scaled, scale } = decimalParts(cnyPerUnit);
const divisor = (10n ** BigInt(currencyMinorUnits(currency))) * scale;
const numerator = BigInt(amount) * scaled * 100n;
const result = (numerator + divisor / 2n) / divisor;
const numeric = Number(result);
if (!Number.isSafeInteger(numeric) || numeric <= 0 || numeric > 2_147_483_647) {
throw new Error("Converted amount is out of range");
}
return numeric;
}
export const ledgerThemeAccents = {
jade: "#087f72",
@ -29,9 +76,12 @@ export type LedgerEntry = {
amount: number;
currency: CurrencyCode;
baseCurrency: CurrencyCode;
baseAmount: number;
exchangeRate: string;
baseAmount: number | null;
exchangeRate: string | null;
exchangeRateDate: string;
exchangeRateEffectiveDate: string | null;
exchangeRateSource: ExchangeRateSource;
conversionStatus: ConversionStatus;
categoryId: string;
note: string;
occurredAt: string;

View File

@ -103,10 +103,13 @@ CREATE TABLE IF NOT EXISTS entries (
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
amount integer NOT NULL CHECK (amount > 0),
currency varchar(3) NOT NULL,
base_currency varchar(3) NOT NULL,
base_amount integer NOT NULL CHECK (base_amount > 0),
exchange_rate varchar(32) NOT NULL,
base_currency varchar(3) NOT NULL DEFAULT 'CNY',
base_amount integer CHECK (base_amount > 0),
exchange_rate varchar(32),
exchange_rate_date date,
exchange_rate_effective_date date,
exchange_rate_source varchar(8) NOT NULL CHECK (exchange_rate_source IN ('manual', 'system')),
conversion_status varchar(8) NOT NULL DEFAULT 'pending',
category_id varchar(100) NOT NULL,
note varchar(500) NOT NULL DEFAULT '',
occurred_at timestamptz NOT NULL,
@ -122,6 +125,57 @@ CREATE TABLE IF NOT EXISTS entries (
ALTER TABLE entries ADD COLUMN IF NOT EXISTS owner_id text REFERENCES users(id) ON DELETE CASCADE;
UPDATE entries SET owner_id = COALESCE(created_by, updated_by) WHERE owner_id IS NULL;
ALTER TABLE entries ALTER COLUMN owner_id SET NOT NULL;
ALTER TABLE entries ALTER COLUMN base_amount DROP NOT NULL;
ALTER TABLE entries ALTER COLUMN exchange_rate DROP NOT NULL;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS exchange_rate_date date;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS exchange_rate_effective_date date;
ALTER TABLE entries ADD COLUMN IF NOT EXISTS conversion_status varchar(8) NOT NULL DEFAULT 'pending';
UPDATE entries
SET base_currency = 'CNY',
base_amount = amount,
exchange_rate = '1',
exchange_rate_date = COALESCE(exchange_rate_date, (occurred_at AT TIME ZONE 'Asia/Shanghai')::date),
exchange_rate_effective_date = COALESCE(exchange_rate_effective_date, (occurred_at AT TIME ZONE 'Asia/Shanghai')::date),
exchange_rate_source = 'system',
conversion_status = 'exact'
WHERE currency = 'CNY';
UPDATE entries
SET base_currency = 'CNY',
base_amount = NULL,
exchange_rate = NULL,
exchange_rate_date = COALESCE(exchange_rate_date, (occurred_at AT TIME ZONE 'Asia/Shanghai')::date),
exchange_rate_effective_date = NULL,
exchange_rate_source = 'system',
conversion_status = 'pending'
WHERE currency <> 'CNY'
AND exchange_rate = '1'
AND exchange_rate_source = 'manual';
UPDATE entries
SET base_currency = 'CNY',
exchange_rate_effective_date = COALESCE(
exchange_rate_effective_date,
exchange_rate_date,
(occurred_at AT TIME ZONE 'Asia/Shanghai')::date
),
conversion_status = 'fallback'
WHERE currency <> 'CNY'
AND base_amount IS NOT NULL
AND exchange_rate IS NOT NULL
AND conversion_status = 'pending';
UPDATE entries
SET exchange_rate_date = (occurred_at AT TIME ZONE 'Asia/Shanghai')::date
WHERE exchange_rate_date IS NULL;
ALTER TABLE entries ALTER COLUMN exchange_rate_date SET NOT NULL;
ALTER TABLE entries DROP CONSTRAINT IF EXISTS entries_conversion_status_check;
ALTER TABLE entries ADD CONSTRAINT entries_conversion_status_check
CHECK (conversion_status IN ('exact', 'fallback', 'pending'));
ALTER TABLE entries DROP CONSTRAINT IF EXISTS entries_conversion_values_check;
ALTER TABLE entries ADD CONSTRAINT entries_conversion_values_check
CHECK (
(conversion_status = 'pending' AND base_amount IS NULL AND exchange_rate IS NULL)
OR
(conversion_status IN ('exact', 'fallback') AND base_amount > 0 AND exchange_rate IS NOT NULL)
);
CREATE TABLE IF NOT EXISTS entry_ledgers (
entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
@ -160,6 +214,36 @@ CREATE TABLE IF NOT EXISTS entry_sync_operations (
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
processed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS exchange_rates (
currency varchar(3) NOT NULL,
rate_date date NOT NULL,
cny_per_unit numeric(24, 12) NOT NULL CHECK (cny_per_unit > 0),
provider varchar(64) NOT NULL,
provider_rate_date date NOT NULL,
fetched_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (currency, rate_date)
);
CREATE INDEX IF NOT EXISTS exchange_rates_currency_date
ON exchange_rates (currency, rate_date DESC);
CREATE TABLE IF NOT EXISTS entry_conversion_jobs (
entry_id text PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
target_version integer NOT NULL,
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz,
lock_token text,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS entry_conversion_jobs_available
ON entry_conversion_jobs (available_at, created_at);
`;
export async function initializeDatabase() {
@ -191,6 +275,12 @@ export async function initializeDatabase() {
SET unlinked_at = NULL, updated_at = now()
WHERE entry_ledgers.unlinked_at IS NOT NULL`,
);
await client.query(
`INSERT INTO entry_conversion_jobs (entry_id, target_version)
SELECT id, version FROM entries
WHERE conversion_status IN ('pending', 'fallback') AND deleted_at IS NULL
ON CONFLICT (entry_id) DO NOTHING`,
);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");

View File

@ -0,0 +1,360 @@
import { randomUUID } from "node:crypto";
import { calculateCnyAmount, currencies, type CurrencyCode, type ConversionStatus } from "@cents/domain";
import type { PoolClient } from "pg";
import { pool } from "./db.js";
const PROVIDER = "ECB";
const API_BASE_URL = process.env.EXCHANGE_RATE_API_URL ?? "https://api.frankfurter.dev";
const APP_TIME_ZONE = process.env.APP_TIME_ZONE ?? "Asia/Shanghai";
const CACHE_LIMIT = 2_000;
const CURRENT_RATE_TTL_MS = 15 * 60_000;
const FALLBACK_TTL_MS = 5 * 60_000;
const PENDING_TTL_MS = 30_000;
const WORKER_INTERVAL_MS = 1_000;
export type ResolvedRate = {
currency: CurrencyCode;
requestedDate: string;
effectiveDate: string | null;
cnyPerUnit: string | null;
status: ConversionStatus;
};
type CachedRate = ResolvedRate & { expiresAt: number };
type StoredRate = {
currency: CurrencyCode;
requestedDate: string;
effectiveDate: string;
cnyPerUnit: string;
};
const rateCache = new Map<string, CachedRate>();
const inFlight = new Map<string, Promise<ResolvedRate>>();
let workerTimer: NodeJS.Timeout | null = null;
let workerRunning = false;
function cacheKey(currency: CurrencyCode, requestedDate: string) {
return `${PROVIDER}:${currency}:${requestedDate}`;
}
function isToday(date: string) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: APP_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return date === `${value.year}-${value.month}-${value.day}`;
}
function cacheResult(result: ResolvedRate) {
const ttl = result.status === "pending"
? PENDING_TTL_MS
: result.status === "fallback"
? FALLBACK_TTL_MS
: isToday(result.requestedDate) ? CURRENT_RATE_TTL_MS : Number.POSITIVE_INFINITY;
const key = cacheKey(result.currency, result.requestedDate);
rateCache.delete(key);
rateCache.set(key, { ...result, expiresAt: Date.now() + ttl });
while (rateCache.size > CACHE_LIMIT) {
const oldestKey = rateCache.keys().next().value as string | undefined;
if (!oldestKey) break;
rateCache.delete(oldestKey);
}
}
function readCache(currency: CurrencyCode, requestedDate: string) {
const key = cacheKey(currency, requestedDate);
const cached = rateCache.get(key);
if (!cached) return null;
if (cached.expiresAt <= Date.now()) {
rateCache.delete(key);
return null;
}
rateCache.delete(key);
rateCache.set(key, cached);
return cached;
}
async function databaseRates(currency: CurrencyCode, requestedDate: string) {
const result = await pool.query<{
currency: CurrencyCode;
requestedDate: string;
effectiveDate: string;
cnyPerUnit: string;
exact: boolean;
}>(
`SELECT currency, rate_date::text AS "requestedDate",
provider_rate_date::text AS "effectiveDate",
cny_per_unit::text AS "cnyPerUnit",
rate_date = $2::date AS exact
FROM exchange_rates
WHERE currency = $1
ORDER BY
(rate_date = $2::date) DESC,
(rate_date <= $2::date) DESC,
CASE WHEN rate_date <= $2::date THEN $2::date - rate_date ELSE rate_date - $2::date END
LIMIT 1`,
[currency, requestedDate],
);
const row = result.rows[0];
if (!row) return null;
return {
currency: row.currency,
requestedDate: toDateString(row.requestedDate),
effectiveDate: toDateString(row.effectiveDate),
cnyPerUnit: row.cnyPerUnit,
exact: row.exact,
};
}
async function fetchProviderRate(currency: CurrencyCode, requestedDate: string): Promise<StoredRate> {
const url = new URL(`/v2/rate/${currency}/CNY`, API_BASE_URL);
url.searchParams.set("date", requestedDate);
url.searchParams.set("providers", PROVIDER);
const response = await fetch(url, { signal: AbortSignal.timeout(4_000) });
if (!response.ok) throw new Error(`Frankfurter returned HTTP ${response.status}`);
const value = await response.json() as Partial<{ date: string; base: string; quote: string; rate: number }>;
if (
value.base !== currency
|| value.quote !== "CNY"
|| typeof value.date !== "string"
|| !/^\d{4}-\d{2}-\d{2}$/.test(value.date)
|| typeof value.rate !== "number"
|| !Number.isFinite(value.rate)
|| value.rate <= 0
) {
throw new Error("Frankfurter returned an invalid rate");
}
return {
currency,
requestedDate,
effectiveDate: value.date,
cnyPerUnit: String(value.rate),
};
}
async function persistRate(rate: StoredRate) {
await pool.query(
`INSERT INTO exchange_rates (
currency, rate_date, cny_per_unit, provider, provider_rate_date, fetched_at
) VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (currency, rate_date) DO UPDATE SET
cny_per_unit = EXCLUDED.cny_per_unit,
provider = EXCLUDED.provider,
provider_rate_date = EXCLUDED.provider_rate_date,
fetched_at = EXCLUDED.fetched_at,
updated_at = now()`,
[rate.currency, rate.requestedDate, rate.cnyPerUnit, PROVIDER, rate.effectiveDate],
);
}
async function resolveUncached(currency: CurrencyCode, requestedDate: string): Promise<ResolvedRate> {
const stored = await databaseRates(currency, requestedDate);
if (stored?.exact) {
return {
currency,
requestedDate,
effectiveDate: stored.effectiveDate,
cnyPerUnit: stored.cnyPerUnit,
status: "exact",
};
}
try {
const providerRate = await fetchProviderRate(currency, requestedDate);
await persistRate(providerRate);
return {
currency,
requestedDate,
effectiveDate: providerRate.effectiveDate,
cnyPerUnit: providerRate.cnyPerUnit,
status: "exact",
};
} catch (error) {
if (stored) {
return {
currency,
requestedDate,
effectiveDate: stored.effectiveDate,
cnyPerUnit: stored.cnyPerUnit,
status: "fallback",
};
}
console.error("Failed to resolve exchange rate", { currency, requestedDate, error });
return { currency, requestedDate, effectiveDate: null, cnyPerUnit: null, status: "pending" };
}
}
export async function resolveExchangeRate(currency: CurrencyCode, requestedDate: string): Promise<ResolvedRate> {
if (currency === "CNY") {
return { currency, requestedDate, effectiveDate: requestedDate, cnyPerUnit: "1", status: "exact" };
}
const cached = readCache(currency, requestedDate);
if (cached) return cached;
const key = cacheKey(currency, requestedDate);
const active = inFlight.get(key);
if (active) return active;
const request = resolveUncached(currency, requestedDate).then((result) => {
cacheResult(result);
return result;
}).finally(() => inFlight.delete(key));
inFlight.set(key, request);
return request;
}
export async function enqueueConversion(client: PoolClient, entryId: string, targetVersion: number) {
await client.query(
`INSERT INTO entry_conversion_jobs (entry_id, target_version)
VALUES ($1, $2)
ON CONFLICT (entry_id) DO UPDATE SET
target_version = EXCLUDED.target_version,
attempts = 0,
available_at = now(),
locked_at = NULL,
lock_token = NULL,
last_error = NULL,
updated_at = now()`,
[entryId, targetVersion],
);
}
async function claimJob() {
const lockToken = randomUUID();
const result = await pool.query<{ entryId: string; targetVersion: number; attempts: number }>(
`UPDATE entry_conversion_jobs
SET locked_at = now(), lock_token = $1, updated_at = now()
WHERE entry_id = (
SELECT entry_id FROM entry_conversion_jobs
WHERE available_at <= now()
AND (locked_at IS NULL OR locked_at < now() - interval '2 minutes')
ORDER BY available_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING entry_id AS "entryId", target_version AS "targetVersion", attempts`,
[lockToken],
);
return result.rows[0] ? { ...result.rows[0], lockToken } : null;
}
async function completeJob(job: Awaited<ReturnType<typeof claimJob>>) {
if (!job) return;
const entryResult = await pool.query<{
id: string;
amount: number;
currency: CurrencyCode;
exchangeRateDate: string;
version: number;
deletedAt: Date | null;
}>(
`SELECT id, amount, currency, exchange_rate_date::text AS "exchangeRateDate",
version, deleted_at AS "deletedAt"
FROM entries WHERE id = $1`,
[job.entryId],
);
const entry = entryResult.rows[0];
if (!entry || entry.deletedAt) {
await pool.query("DELETE FROM entry_conversion_jobs WHERE entry_id = $1 AND lock_token = $2", [job.entryId, job.lockToken]);
return;
}
if (entry.version !== job.targetVersion) {
await pool.query(
`UPDATE entry_conversion_jobs SET target_version = $1, attempts = 0,
available_at = now(), locked_at = NULL, lock_token = NULL, updated_at = now()
WHERE entry_id = $2 AND lock_token = $3`,
[entry.version, entry.id, job.lockToken],
);
return;
}
const requestedDate = toDateString(entry.exchangeRateDate);
try {
const rate = await resolveExchangeRate(entry.currency, requestedDate);
if (!rate.cnyPerUnit || !rate.effectiveDate || rate.status === "pending") {
throw new Error("No exchange rate is available");
}
const baseAmount = calculateCnyAmount(entry.amount, entry.currency, rate.cnyPerUnit);
const client = await pool.connect();
try {
await client.query("BEGIN");
const updated = await client.query(
`UPDATE entries SET
base_currency = 'CNY',
base_amount = $1,
exchange_rate = $2,
exchange_rate_effective_date = $3,
exchange_rate_source = 'system',
conversion_status = $4,
server_updated_at = now()
WHERE id = $5 AND version = $6`,
[baseAmount, rate.cnyPerUnit, rate.effectiveDate, rate.status, entry.id, entry.version],
);
if (!updated.rowCount) {
await client.query(
`UPDATE entry_conversion_jobs SET locked_at = NULL, lock_token = NULL,
available_at = now(), updated_at = now()
WHERE entry_id = $1 AND lock_token = $2`,
[entry.id, job.lockToken],
);
} else if (rate.status === "exact") {
await client.query(
"DELETE FROM entry_conversion_jobs WHERE entry_id = $1 AND lock_token = $2",
[entry.id, job.lockToken],
);
} else {
await client.query(
`UPDATE entry_conversion_jobs SET attempts = attempts + 1,
available_at = now() + interval '1 hour',
locked_at = NULL, lock_token = NULL, last_error = NULL, updated_at = now()
WHERE entry_id = $1 AND lock_token = $2`,
[entry.id, job.lockToken],
);
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
} catch (error) {
const delayMinutes = Math.min(60, 2 ** Math.min(job.attempts, 5));
await pool.query(
`UPDATE entry_conversion_jobs SET attempts = attempts + 1,
available_at = now() + ($1 * interval '1 minute'),
locked_at = NULL, lock_token = NULL, last_error = $2, updated_at = now()
WHERE entry_id = $3 AND lock_token = $4`,
[delayMinutes, error instanceof Error ? error.message.slice(0, 500) : "Unknown conversion error", entry.id, job.lockToken],
);
}
}
async function runWorker() {
if (workerRunning) return;
workerRunning = true;
try {
for (let count = 0; count < 10; count += 1) {
const job = await claimJob();
if (!job) break;
await completeJob(job);
}
} finally {
workerRunning = false;
}
}
export function startExchangeRateWorker() {
if (workerTimer) return;
void runWorker();
workerTimer = setInterval(() => void runWorker(), WORKER_INTERVAL_MS);
workerTimer.unref();
}
function toDateString(value: string) {
return value.slice(0, 10);
}
export function isCurrencyCode(value: unknown): value is CurrencyCode {
return typeof value === "string" && Object.hasOwn(currencies, value);
}

View File

@ -1,10 +1,22 @@
import cookie from "@fastify/cookie";
import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import { ledgerThemeAccents, type LedgerEntry, type LedgerThemeId, type SyncOperation } from "@cents/domain";
import {
ledgerThemeAccents,
type CurrencyCode,
type LedgerEntry,
type LedgerThemeId,
type SyncOperation,
} from "@cents/domain";
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js";
import { initializeDatabase, pool } from "./db.js";
import {
enqueueConversion,
isCurrencyCode,
resolveExchangeRate,
startExchangeRateWorker,
} from "./exchange-rates.js";
import {
clearSessionCookie,
createId,
@ -28,6 +40,7 @@ await server.register(cookie);
await server.register(helmet, { contentSecurityPolicy: false });
await server.register(rateLimit, { max: 120, timeWindow: "1 minute" });
await initializeDatabase();
startExchangeRateWorker();
server.addHook("onRequest", async (request, reply) => {
if (!["POST", "PATCH", "PUT", "DELETE"].includes(request.method)) return;
@ -48,6 +61,7 @@ server.addHook("onSend", async (request, reply, payload) => {
request.url.startsWith("/api/auth/")
|| request.url.startsWith("/api/invitations/")
|| request.url.startsWith("/api/ledger-invitations/")
|| request.url.startsWith("/api/exchange-rates/")
|| request.url.startsWith("/api/sync/")
|| request.url === "/api/me"
) {
@ -115,8 +129,6 @@ function invitationStatus(invitation: { expiresAt: Date | string; acceptedAt: Da
return "valid";
}
const currencies = new Set(["CNY", "USD", "EUR", "JPY", "HKD"]);
function validLedgerTheme(value: unknown): value is LedgerThemeId {
return typeof value === "string" && Object.hasOwn(ledgerThemeAccents, value);
}
@ -131,6 +143,11 @@ function validDate(value: unknown) {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function validDateOnly(value: unknown) {
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
}
function validLedgerIds(value: unknown): value is string[] {
return Array.isArray(value) && value.length > 0 && value.length <= 20
&& value.every((ledgerId) => typeof ledgerId === "string" && ledgerId.length > 0 && ledgerId.length <= 100)
@ -145,11 +162,19 @@ function validEntry(value: unknown): value is LedgerEntry {
&& validLedgerIds(entry.ledgerIds)
&& (entry.type === "expense" || entry.type === "income")
&& Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647
&& typeof entry.currency === "string" && currencies.has(entry.currency)
&& typeof entry.baseCurrency === "string" && currencies.has(entry.baseCurrency)
&& Number.isSafeInteger(entry.baseAmount) && entry.baseAmount! > 0 && entry.baseAmount! <= 2_147_483_647
&& typeof entry.exchangeRate === "string" && entry.exchangeRate.length > 0 && entry.exchangeRate.length <= 32
&& isCurrencyCode(entry.currency)
&& entry.baseCurrency === "CNY"
&& (entry.baseAmount === null
|| (Number.isSafeInteger(entry.baseAmount) && entry.baseAmount! > 0 && entry.baseAmount! <= 2_147_483_647))
&& (entry.exchangeRate === null
|| (typeof entry.exchangeRate === "string" && /^\d+(?:\.\d+)?$/.test(entry.exchangeRate) && entry.exchangeRate.length <= 32))
&& validDateOnly(entry.exchangeRateDate)
&& (entry.exchangeRateEffectiveDate === null || validDateOnly(entry.exchangeRateEffectiveDate))
&& (entry.exchangeRateSource === "manual" || entry.exchangeRateSource === "system")
&& (entry.conversionStatus === "exact" || entry.conversionStatus === "fallback" || entry.conversionStatus === "pending")
&& (entry.conversionStatus === "pending"
? entry.baseAmount === null && entry.exchangeRate === null
: entry.baseAmount !== null && entry.exchangeRate !== null && entry.exchangeRateEffectiveDate !== null)
&& typeof entry.categoryId === "string" && entry.categoryId.length > 0 && entry.categoryId.length <= 100
&& typeof entry.note === "string" && entry.note.length <= 500
&& validDate(entry.occurredAt) && validDate(entry.createdAt) && validDate(entry.updatedAt)
@ -267,7 +292,7 @@ server.post<{ Body: { name?: string; color?: string; theme?: string; defaultCurr
const defaultCurrency = request.body.defaultCurrency ?? "CNY";
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称应为 1 至 40 个字符" });
if (!/^#[0-9a-f]{6}$/i.test(color)) return reply.code(400).send({ error: "账本颜色无效" });
if (!["CNY", "USD", "EUR", "JPY", "HKD"].includes(defaultCurrency)) {
if (!isCurrencyCode(defaultCurrency)) {
return reply.code(400).send({ error: "默认币种无效" });
}
@ -331,16 +356,31 @@ server.patch<{
}
const theme = request.body.theme ?? ledgerThemeFromColor(request.body.color);
const color = ledgerThemeAccents[theme];
const defaultCurrency = request.body.defaultCurrency ?? "CNY";
if (!isCurrencyCode(defaultCurrency)) return reply.code(400).send({ error: "默认币种无效" });
const result = await pool.query(
`UPDATE ledgers SET name = $1, color = $2, theme = $3, default_currency = $4, updated_at = now()
WHERE id = $5
RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
[personalLedger.rows[0]?.isPersonal ? PERSONAL_LEDGER_NAME : name, color, theme, request.body.defaultCurrency ?? "CNY", request.params.ledgerId],
[personalLedger.rows[0]?.isPersonal ? PERSONAL_LEDGER_NAME : name, color, theme, defaultCurrency, request.params.ledgerId],
);
return { ledger: result.rows[0] };
});
server.get<{
Params: { currency: string; date: string };
}>("/api/exchange-rates/:currency/:date", async (request, reply) => {
const user = await requireUser(request, reply);
if (!user) return;
if (!isCurrencyCode(request.params.currency) || !validDateOnly(request.params.date)) {
return reply.code(400).send({ error: "币种或汇率日期无效" });
}
return {
rate: await resolveExchangeRate(request.params.currency, request.params.date),
};
});
// Application invitations create accounts but never grant access to an existing ledger.
server.get<{ Params: { key: string } }>("/api/invitations/:key", async (request, reply) => {
const user = await currentUser(request, reply);
@ -515,14 +555,30 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
continue;
}
const existing = await client.query<{ ownerId: string; canAccess: boolean }>(
const existing = await client.query<{
ownerId: string;
canAccess: boolean;
amount: number;
currency: CurrencyCode;
exchangeRateDate: string;
baseAmount: number | null;
exchangeRate: string | null;
exchangeRateEffectiveDate: string | null;
exchangeRateSource: "manual" | "system";
conversionStatus: "exact" | "fallback" | "pending";
}>(
`SELECT e.owner_id AS "ownerId",
(e.owner_id = $2 OR EXISTS (
SELECT 1 FROM entry_ledgers el
JOIN ledger_members m ON m.ledger_id = el.ledger_id
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
AND m.user_id = $2 AND m.removed_at IS NULL
)) AS "canAccess"
)) AS "canAccess",
e.amount, e.currency, e.exchange_rate_date::text AS "exchangeRateDate",
e.base_amount AS "baseAmount", e.exchange_rate AS "exchangeRate",
e.exchange_rate_effective_date::text AS "exchangeRateEffectiveDate",
e.exchange_rate_source AS "exchangeRateSource",
e.conversion_status AS "conversionStatus"
FROM entries e WHERE e.id = $1`,
[operation.entityId, user.id],
);
@ -557,14 +613,44 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
const targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])];
const entry = operation.payload;
const existingEntry = existing.rows[0];
const existingRateDate = existingEntry?.exchangeRateDate?.slice(0, 10);
const conversionChanged = !existingEntry
|| existingEntry.amount !== entry.amount
|| existingEntry.currency !== entry.currency
|| existingRateDate !== entry.exchangeRateDate;
const conversion = entry.currency === "CNY"
? {
baseAmount: entry.amount,
exchangeRate: "1",
effectiveDate: entry.exchangeRateDate,
source: "system" as const,
status: "exact" as const,
}
: !conversionChanged && existingEntry
? {
baseAmount: existingEntry.baseAmount,
exchangeRate: existingEntry.exchangeRate,
effectiveDate: existingEntry.exchangeRateEffectiveDate,
source: existingEntry.exchangeRateSource,
status: existingEntry.conversionStatus,
}
: {
baseAmount: null,
exchangeRate: null,
effectiveDate: null,
source: "system" as const,
status: "pending" as const,
};
const writeResult = await client.query(
`INSERT INTO entries (
id, owner_id, type, amount, currency, base_currency, base_amount,
exchange_rate, exchange_rate_source, category_id, note, occurred_at,
exchange_rate, exchange_rate_date, exchange_rate_effective_date,
exchange_rate_source, conversion_status, category_id, note, occurred_at,
created_by, updated_by, created_at, updated_at, deleted_at, version
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18
$1, $2, $3, $4, $5, 'CNY', $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20
)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
@ -573,7 +659,10 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
base_currency = EXCLUDED.base_currency,
base_amount = EXCLUDED.base_amount,
exchange_rate = EXCLUDED.exchange_rate,
exchange_rate_date = EXCLUDED.exchange_rate_date,
exchange_rate_effective_date = EXCLUDED.exchange_rate_effective_date,
exchange_rate_source = EXCLUDED.exchange_rate_source,
conversion_status = EXCLUDED.conversion_status,
category_id = EXCLUDED.category_id,
note = EXCLUDED.note,
occurred_at = EXCLUDED.occurred_at,
@ -585,8 +674,9 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
WHERE entries.version < EXCLUDED.version
OR (entries.version = EXCLUDED.version AND entries.updated_at <= EXCLUDED.updated_at)`,
[
entry.id, user.id, entry.type, entry.amount, entry.currency, entry.baseCurrency,
entry.baseAmount, entry.exchangeRate, entry.exchangeRateSource, entry.categoryId,
entry.id, user.id, entry.type, entry.amount, entry.currency,
conversion.baseAmount, conversion.exchangeRate, entry.exchangeRateDate,
conversion.effectiveDate, conversion.source, conversion.status, entry.categoryId,
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
entry.deletedAt, entry.version,
],
@ -617,6 +707,14 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
[entry.id, targetLedgerIds],
);
}
if (
writeResult.rowCount
&& operation.action !== "delete"
&& entry.currency !== "CNY"
&& (conversionChanged || conversion.status !== "exact")
) {
await enqueueConversion(client, entry.id, entry.version);
}
await client.query("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);
await client.query(
"INSERT INTO entry_sync_operations (id, user_id) VALUES ($1, $2)",
@ -655,7 +753,11 @@ server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (reques
) AS "ledgerIds",
e.type, e.amount,
e.currency, e.base_currency AS "baseCurrency", e.base_amount AS "baseAmount",
e.exchange_rate AS "exchangeRate", e.exchange_rate_source AS "exchangeRateSource",
e.exchange_rate AS "exchangeRate",
e.exchange_rate_date::text AS "exchangeRateDate",
e.exchange_rate_effective_date::text AS "exchangeRateEffectiveDate",
e.exchange_rate_source AS "exchangeRateSource",
e.conversion_status AS "conversionStatus",
e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
e.created_at AS "createdAt", e.updated_at AS "updatedAt",