feat: add asynchronous multi-currency conversion
This commit is contained in:
parent
d3723b338b
commit
63c6cec81b
|
|
@ -19,3 +19,5 @@ TEST_MODE=false
|
||||||
POSTGRES_DB=cents
|
POSTGRES_DB=cents
|
||||||
POSTGRES_USER=cents
|
POSTGRES_USER=cents
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
|
APP_TIME_ZONE=Asia/Shanghai
|
||||||
|
EXCHANGE_RATE_API_URL=https://api.frankfurter.dev
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<script setup lang="ts">
|
<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 { 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 { entryTypes, type Category } from "../data/categories";
|
||||||
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
|
import CategoryCoordinateSelector from "./CategoryCoordinateSelector.vue";
|
||||||
|
import CurrencyPicker from "./CurrencyPicker.vue";
|
||||||
|
|
||||||
type SavedEntry = {
|
type SavedEntry = {
|
||||||
type: EntryType;
|
type: EntryType;
|
||||||
|
|
@ -12,12 +13,14 @@ type SavedEntry = {
|
||||||
note: string;
|
note: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
ledgerIds: string[];
|
ledgerIds: string[];
|
||||||
|
currency: CurrencyCode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
ledgerId: string;
|
ledgerId: string;
|
||||||
ledgerName: string;
|
ledgerName: string;
|
||||||
personalLedgerId: string;
|
personalLedgerId: string;
|
||||||
|
defaultCurrency: CurrencyCode;
|
||||||
ledgerOptions: Array<{ id: string; name: string; gradient: string; isPersonal: boolean }>;
|
ledgerOptions: Array<{ id: string; name: string; gradient: string; isPersonal: boolean }>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|
@ -27,6 +30,7 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const expression = ref("0");
|
const expression = ref("0");
|
||||||
|
const currency = ref<CurrencyCode>(props.defaultCurrency);
|
||||||
const entryType = ref<EntryType>("expense");
|
const entryType = ref<EntryType>("expense");
|
||||||
const categoryPath = ref<Category[]>([]);
|
const categoryPath = ref<Category[]>([]);
|
||||||
const note = ref("");
|
const note = ref("");
|
||||||
|
|
@ -35,6 +39,7 @@ const dateInput = ref<HTMLInputElement | null>(null);
|
||||||
const selectorOpen = ref(false);
|
const selectorOpen = ref(false);
|
||||||
const noteEditing = ref(false);
|
const noteEditing = ref(false);
|
||||||
const ledgerPickerOpen = ref(false);
|
const ledgerPickerOpen = ref(false);
|
||||||
|
const currencyPickerOpen = ref(false);
|
||||||
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
|
const selectedLedgerIds = ref([...new Set([props.ledgerId, props.personalLedgerId].filter(Boolean))]);
|
||||||
|
|
||||||
const typeOption = computed(() => entryTypes.find((item) => item.id === entryType.value)!);
|
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 categoryDisplayLabel = computed(() => selectedCategory.value?.label ?? typeOption.value.label);
|
||||||
const calculatedAmount = computed(() => evaluateExpression(expression.value));
|
const calculatedAmount = computed(() => evaluateExpression(expression.value));
|
||||||
|
const minorUnits = computed(() => currencyMinorUnits(currency.value));
|
||||||
const hasCalculation = computed(() => /[+-]/.test(expression.value));
|
const hasCalculation = computed(() => /[+-]/.test(expression.value));
|
||||||
const calculationLabel = computed(() => expression.value.replaceAll("-", "−").replaceAll("+", " + ").replaceAll("−", " − "));
|
const calculationLabel = computed(() => expression.value.replaceAll("-", "−").replaceAll("+", " + ").replaceAll("−", " − "));
|
||||||
const displayAmount = computed(() =>
|
const displayAmount = computed(() =>
|
||||||
new Intl.NumberFormat("zh-CN", {
|
new Intl.NumberFormat("zh-CN", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: minorUnits.value,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: minorUnits.value,
|
||||||
}).format(calculatedAmount.value),
|
}).format(calculatedAmount.value),
|
||||||
);
|
);
|
||||||
|
const currencySymbol = computed(() => currencies[currency.value].symbol);
|
||||||
const selectedLedgerLabel = computed(() => {
|
const selectedLedgerLabel = computed(() => {
|
||||||
const visibleNames = props.ledgerOptions
|
const visibleNames = props.ledgerOptions
|
||||||
.filter((ledger) => !ledger.isPersonal && selectedLedgerIds.value.includes(ledger.id))
|
.filter((ledger) => !ledger.isPersonal && selectedLedgerIds.value.includes(ledger.id))
|
||||||
|
|
@ -71,10 +78,16 @@ const dateLabel = computed(() => {
|
||||||
return `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
return `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
});
|
});
|
||||||
const canSave = computed(() => {
|
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;
|
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) {
|
function toLocalDateTime(date: Date) {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = pad(date.getMonth() + 1);
|
const month = pad(date.getMonth() + 1);
|
||||||
|
|
@ -106,7 +119,8 @@ function evaluateExpression(value: string) {
|
||||||
if (operand === "" || operand === ".") return;
|
if (operand === "" || operand === ".") return;
|
||||||
const right = Number(operand);
|
const right = Number(operand);
|
||||||
result = operator === "+" ? result + right : result - right;
|
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;
|
return result;
|
||||||
|
|
@ -119,7 +133,7 @@ function currentOperand() {
|
||||||
function appendDigit(digit: string) {
|
function appendDigit(digit: string) {
|
||||||
const operand = currentOperand();
|
const operand = currentOperand();
|
||||||
const decimalPlaces = operand.includes(".") ? operand.split(".")[1].length : 0;
|
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;
|
if (expression.value === "0") expression.value = digit;
|
||||||
else if (operand === "0") expression.value = `${expression.value.slice(0, -1)}${digit}`;
|
else if (operand === "0") expression.value = `${expression.value.slice(0, -1)}${digit}`;
|
||||||
|
|
@ -127,6 +141,7 @@ function appendDigit(digit: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendDecimal() {
|
function appendDecimal() {
|
||||||
|
if (minorUnits.value === 0) return;
|
||||||
const operand = currentOperand();
|
const operand = currentOperand();
|
||||||
if (operand.includes(".")) return;
|
if (operand.includes(".")) return;
|
||||||
expression.value += operand === "" ? "0." : ".";
|
expression.value += operand === "" ? "0." : ".";
|
||||||
|
|
@ -170,6 +185,12 @@ function openLedgerPicker() {
|
||||||
ledgerPickerOpen.value = true;
|
ledgerPickerOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCurrencyPicker() {
|
||||||
|
confirmCategory();
|
||||||
|
noteEditing.value = false;
|
||||||
|
currencyPickerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleLedger(ledgerId: string) {
|
function toggleLedger(ledgerId: string) {
|
||||||
if (ledgerId === props.personalLedgerId) return;
|
if (ledgerId === props.personalLedgerId) return;
|
||||||
if (selectedLedgerIds.value.includes(ledgerId)) {
|
if (selectedLedgerIds.value.includes(ledgerId)) {
|
||||||
|
|
@ -182,7 +203,7 @@ function toggleLedger(ledgerId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEntry() {
|
function saveEntry() {
|
||||||
const amount = Math.round(calculatedAmount.value * 100);
|
const amount = Math.round(calculatedAmount.value * 10 ** minorUnits.value);
|
||||||
if (!canSave.value) return;
|
if (!canSave.value) return;
|
||||||
|
|
||||||
emit("save", {
|
emit("save", {
|
||||||
|
|
@ -192,6 +213,7 @@ function saveEntry() {
|
||||||
note: note.value.trim(),
|
note: note.value.trim(),
|
||||||
occurredAt: new Date(occurredAt.value).toISOString(),
|
occurredAt: new Date(occurredAt.value).toISOString(),
|
||||||
ledgerIds: selectedLedgerIds.value,
|
ledgerIds: selectedLedgerIds.value,
|
||||||
|
currency: currency.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -205,7 +227,9 @@ function saveEntry() {
|
||||||
<header class="drawer-header">
|
<header class="drawer-header">
|
||||||
<div class="amount-block">
|
<div class="amount-block">
|
||||||
<div class="amount-result">
|
<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>
|
<strong class="amount-value">{{ displayAmount }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span>
|
<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>
|
<button class="quick-ledger-picker-done" type="button" @click="ledgerPickerOpen = false"><Check :size="18" />完成</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CurrencyPicker v-model="currency" :open="currencyPickerOpen" @close="currencyPickerOpen = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { CurrencyCode } from "@cents/domain";
|
||||||
import { BarChart3, BookOpen, Check, CircleUserRound, LibraryBig, Plus } from "@lucide/vue";
|
import { BarChart3, BookOpen, Check, CircleUserRound, LibraryBig, Plus } from "@lucide/vue";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
|
@ -31,12 +32,12 @@ async function saveEntry(input: {
|
||||||
note: string;
|
note: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
ledgerIds: string[];
|
ledgerIds: string[];
|
||||||
|
currency: CurrencyCode;
|
||||||
}) {
|
}) {
|
||||||
const ledger = ledgerStore.currentLedger;
|
const ledger = ledgerStore.currentLedger;
|
||||||
if (!ledger) return;
|
if (!ledger) return;
|
||||||
await entryStore.addEntry(createEntry({
|
await entryStore.addEntry(createEntry({
|
||||||
...input,
|
...input,
|
||||||
currency: ledger.defaultCurrency,
|
|
||||||
createdBy: authStore.user?.id,
|
createdBy: authStore.user?.id,
|
||||||
}));
|
}));
|
||||||
drawerOpen.value = false;
|
drawerOpen.value = false;
|
||||||
|
|
@ -65,6 +66,7 @@ async function saveEntry(input: {
|
||||||
:ledger-name="ledgerStore.currentLedger.name"
|
:ledger-name="ledgerStore.currentLedger.name"
|
||||||
:ledger-options="ledgerOptions"
|
:ledger-options="ledgerOptions"
|
||||||
:personal-ledger-id="ledgerStore.personalLedger?.id ?? ''"
|
:personal-ledger-id="ledgerStore.personalLedger?.id ?? ''"
|
||||||
|
:default-currency="ledgerStore.currentLedger.defaultCurrency"
|
||||||
@close="drawerOpen = false"
|
@close="drawerOpen = false"
|
||||||
@save="saveEntry"
|
@save="saveEntry"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Dexie, { type EntityTable } from "dexie";
|
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";
|
import type { LedgerRecord, UserPreference } from "./ledgers";
|
||||||
|
|
||||||
export type SyncMetadata = {
|
export type SyncMetadata = {
|
||||||
|
|
@ -8,12 +8,23 @@ export type SyncMetadata = {
|
||||||
updatedAt: string;
|
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 & {
|
export const db = new Dexie("cents") as Dexie & {
|
||||||
entries: EntityTable<LedgerEntry, "id">;
|
entries: EntityTable<LedgerEntry, "id">;
|
||||||
syncOperations: EntityTable<SyncOperation, "id">;
|
syncOperations: EntityTable<SyncOperation, "id">;
|
||||||
ledgers: EntityTable<LedgerRecord, "id">;
|
ledgers: EntityTable<LedgerRecord, "id">;
|
||||||
userPreferences: EntityTable<UserPreference, "id">;
|
userPreferences: EntityTable<UserPreference, "id">;
|
||||||
syncMetadata: EntityTable<SyncMetadata, "id">;
|
syncMetadata: EntityTable<SyncMetadata, "id">;
|
||||||
|
exchangeRates: EntityTable<LocalExchangeRate, "id">;
|
||||||
};
|
};
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
|
|
@ -63,3 +74,42 @@ db.version(4).stores({
|
||||||
delete operation.ledgerId;
|
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())}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ const baseCurrency: CurrencyCode = "CNY";
|
||||||
|
|
||||||
export function createEntry(input: EntryInput): LedgerEntry {
|
export function createEntry(input: EntryInput): LedgerEntry {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const exchangeRateDate = localDate(input.occurredAt);
|
||||||
|
const isCny = input.currency === "CNY";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
|
|
@ -25,9 +27,12 @@ export function createEntry(input: EntryInput): LedgerEntry {
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
currency: input.currency,
|
currency: input.currency,
|
||||||
baseCurrency,
|
baseCurrency,
|
||||||
baseAmount: input.amount,
|
baseAmount: isCny ? input.amount : null,
|
||||||
exchangeRate: "1",
|
exchangeRate: isCny ? "1" : null,
|
||||||
exchangeRateSource: "manual",
|
exchangeRateDate,
|
||||||
|
exchangeRateEffectiveDate: isCny ? exchangeRateDate : null,
|
||||||
|
exchangeRateSource: "system",
|
||||||
|
conversionStatus: isCny ? "exact" : "pending",
|
||||||
categoryId: input.categoryId,
|
categoryId: input.categoryId,
|
||||||
note: input.note,
|
note: input.note,
|
||||||
occurredAt: input.occurredAt,
|
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[] {
|
export function createDemoEntries(): LedgerEntry[] {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const at = (daysAgo: number, hours: number, minutes: number) => {
|
const at = (daysAgo: number, hours: number, minutes: number) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { defineStore } from "pinia";
|
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 { apiRequest } from "../data/api";
|
||||||
import { db } from "../data/db";
|
import { db } from "../data/db";
|
||||||
|
import { localDate } from "../data/entries";
|
||||||
import { createId } from "../data/ids";
|
import { createId } from "../data/ids";
|
||||||
import { useAuthStore } from "./auth";
|
import { useAuthStore } from "./auth";
|
||||||
|
|
||||||
|
|
@ -137,6 +138,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.refreshLocalEntries();
|
await this.refreshLocalEntries();
|
||||||
|
void this.resolveLocalConversion(entry.id);
|
||||||
void this.syncEntries();
|
void this.syncEntries();
|
||||||
},
|
},
|
||||||
async updateEntry(entry: LedgerEntry) {
|
async updateEntry(entry: LedgerEntry) {
|
||||||
|
|
@ -144,10 +146,25 @@ export const useEntryStore = defineStore("entries", {
|
||||||
const existing = await db.entries.get(entry.id);
|
const existing = await db.entries.get(entry.id);
|
||||||
if (!existing || existing.deletedAt) throw new Error("Entry not found");
|
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 = {
|
const updated: LedgerEntry = {
|
||||||
...existing,
|
...existing,
|
||||||
...entry,
|
...entry,
|
||||||
ledgerIds: [...new Set(entry.ledgerIds)],
|
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(),
|
updatedAt: new Date().toISOString(),
|
||||||
updatedBy: auth.user?.id ?? existing.updatedBy,
|
updatedBy: auth.user?.id ?? existing.updatedBy,
|
||||||
version: existing.version + 1,
|
version: existing.version + 1,
|
||||||
|
|
@ -170,9 +187,75 @@ export const useEntryStore = defineStore("entries", {
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.refreshLocalEntries();
|
await this.refreshLocalEntries();
|
||||||
|
if (conversionChanged) void this.resolveLocalConversion(updated.id);
|
||||||
void this.syncEntries();
|
void this.syncEntries();
|
||||||
return updated;
|
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) {
|
async deleteEntry(entryId: string) {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const existing = await db.entries.get(entryId);
|
const existing = await db.entries.get(entryId);
|
||||||
|
|
|
||||||
|
|
@ -744,11 +744,20 @@ input:focus-visible {
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-prefix {
|
.amount-prefix {
|
||||||
|
min-width: 30px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px dashed #9baba6;
|
||||||
|
padding: 0 2px 1px;
|
||||||
|
background: transparent;
|
||||||
color: #70807c;
|
color: #70807c;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 650;
|
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 {
|
.amount-value {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #172421;
|
color: #172421;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EntryType, LedgerEntry } from "@cents/domain";
|
import {
|
||||||
|
currencies,
|
||||||
|
currencyMinorUnits,
|
||||||
|
type CurrencyCode,
|
||||||
|
type EntryType,
|
||||||
|
type LedgerEntry,
|
||||||
|
} from "@cents/domain";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
|
@ -25,6 +31,7 @@ import {
|
||||||
import { ledgerTheme } from "../data/ledgers";
|
import { ledgerTheme } from "../data/ledgers";
|
||||||
import { useEntryStore } from "../stores/entries";
|
import { useEntryStore } from "../stores/entries";
|
||||||
import { useLedgerStore } from "../stores/ledgers";
|
import { useLedgerStore } from "../stores/ledgers";
|
||||||
|
import CurrencyPicker from "../components/CurrencyPicker.vue";
|
||||||
|
|
||||||
type CategorySheetMode = "parent" | "child";
|
type CategorySheetMode = "parent" | "child";
|
||||||
|
|
||||||
|
|
@ -40,8 +47,10 @@ const errorMessage = ref("");
|
||||||
const categorySheetMode = ref<CategorySheetMode | null>(null);
|
const categorySheetMode = ref<CategorySheetMode | null>(null);
|
||||||
const ledgerPickerOpen = ref(false);
|
const ledgerPickerOpen = ref(false);
|
||||||
const dateInput = ref<HTMLInputElement | null>(null);
|
const dateInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const currencyPickerOpen = ref(false);
|
||||||
|
|
||||||
const amount = ref("");
|
const amount = ref("");
|
||||||
|
const currency = ref<CurrencyCode>("CNY");
|
||||||
const ledgerIds = ref<string[]>([]);
|
const ledgerIds = ref<string[]>([]);
|
||||||
const parentCategoryId = ref("");
|
const parentCategoryId = ref("");
|
||||||
const childCategoryId = ref("");
|
const childCategoryId = ref("");
|
||||||
|
|
@ -92,6 +101,13 @@ const createdAtLabel = computed(() => {
|
||||||
hour12: false,
|
hour12: false,
|
||||||
}).format(new Date(entry.value.createdAt));
|
}).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 () => {
|
onMounted(async () => {
|
||||||
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
|
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
|
||||||
|
|
@ -102,7 +118,9 @@ onMounted(async () => {
|
||||||
|
|
||||||
function hydrate(value: LedgerEntry) {
|
function hydrate(value: LedgerEntry) {
|
||||||
entry.value = value;
|
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)))];
|
ledgerIds.value = [...new Set([...value.ledgerIds, ledgerStore.personalLedger?.id].filter((id): id is string => Boolean(id)))];
|
||||||
note.value = value.note;
|
note.value = value.note;
|
||||||
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
|
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
|
||||||
|
|
@ -166,12 +184,11 @@ async function saveEntry() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
try {
|
try {
|
||||||
const cents = Math.round(numericAmount * 100);
|
const minorAmount = Math.round(numericAmount * 10 ** currencyMinorUnits(currency.value));
|
||||||
const exchangeRate = Number(entry.value.exchangeRate) || 1;
|
|
||||||
const updated = await store.updateEntry({
|
const updated = await store.updateEntry({
|
||||||
...entry.value,
|
...entry.value,
|
||||||
amount: cents,
|
amount: minorAmount,
|
||||||
baseAmount: Math.round(cents * exchangeRate),
|
currency: currency.value,
|
||||||
ledgerIds: [...ledgerIds.value],
|
ledgerIds: [...ledgerIds.value],
|
||||||
categoryId: childCategoryId.value || parentCategoryId.value || entryType.value,
|
categoryId: childCategoryId.value || parentCategoryId.value || entryType.value,
|
||||||
note: note.value.trim(),
|
note: note.value.trim(),
|
||||||
|
|
@ -228,9 +245,12 @@ async function deleteEntry() {
|
||||||
</div>
|
</div>
|
||||||
<span class="entry-type-label">{{ typeOption.label }}</span>
|
<span class="entry-type-label">{{ typeOption.label }}</span>
|
||||||
<label class="entry-amount-input">
|
<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="金额" />
|
<input v-model="amount" inputmode="decimal" aria-label="金额" />
|
||||||
</label>
|
</label>
|
||||||
|
<span v-if="conversionLabel" class="entry-conversion-label">{{ conversionLabel }}</span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="entry-edit-fields" aria-label="编辑条目">
|
<section class="entry-edit-fields" aria-label="编辑条目">
|
||||||
|
|
@ -361,6 +381,8 @@ async function deleteEntry() {
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<CurrencyPicker v-model="currency" :open="currencyPickerOpen" @close="currencyPickerOpen = false" />
|
||||||
|
|
||||||
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
|
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
|
||||||
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
|
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
|
||||||
<section class="quick-ledger-picker" aria-label="选择关联账本">
|
<section class="quick-ledger-picker" aria-label="选择关联账本">
|
||||||
|
|
@ -470,7 +492,13 @@ async function deleteEntry() {
|
||||||
color: #0d8b67;
|
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-size: 23px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
@ -488,6 +516,12 @@ async function deleteEntry() {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-conversion-label {
|
||||||
|
min-height: 16px;
|
||||||
|
color: #7b8884;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-edit-fields {
|
.entry-edit-fields {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
border-top: 1px solid #dce7e4;
|
border-top: 1px solid #dce7e4;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { LedgerThemeId } from "@cents/domain";
|
import type { CurrencyCode, LedgerThemeId } from "@cents/domain";
|
||||||
import { ArrowLeft, Check } from "@lucide/vue";
|
import { ArrowLeft, Check } from "@lucide/vue";
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
@ -10,7 +10,7 @@ const router = useRouter();
|
||||||
const ledgerStore = useLedgerStore();
|
const ledgerStore = useLedgerStore();
|
||||||
const name = ref("");
|
const name = ref("");
|
||||||
const themeId = ref<LedgerThemeId>("jade");
|
const themeId = ref<LedgerThemeId>("jade");
|
||||||
const currency = ref<"CNY" | "USD" | "EUR" | "JPY" | "HKD">("CNY");
|
const currency = ref<CurrencyCode>("CNY");
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -44,7 +44,7 @@ async function save() {
|
||||||
<form class="settings-form" @submit.prevent="save">
|
<form class="settings-form" @submit.prevent="save">
|
||||||
<section>
|
<section>
|
||||||
<label><span>账本名称</span><input v-model="name" maxlength="24" :disabled="ledgerStore.currentLedger?.isPersonal" required /></label>
|
<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>
|
||||||
<section class="color-setting">
|
<section class="color-setting">
|
||||||
<span>账本主题</span>
|
<span>账本主题</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { LedgerEntry } from "@cents/domain";
|
import { formatCurrencyAmount, type LedgerEntry } from "@cents/domain";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
CloudOff,
|
CloudOff,
|
||||||
Copy,
|
Copy,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
|
Clock3,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -59,6 +60,7 @@ const monthlyEntries = computed(() => {
|
||||||
const totals = computed(() => {
|
const totals = computed(() => {
|
||||||
return monthlyEntries.value.reduce(
|
return monthlyEntries.value.reduce(
|
||||||
(result, entry) => {
|
(result, entry) => {
|
||||||
|
if (entry.baseAmount === null) return result;
|
||||||
if (entry.type === "income") result.income += entry.baseAmount;
|
if (entry.type === "income") result.income += entry.baseAmount;
|
||||||
else result.expense += entry.baseAmount;
|
else result.expense += entry.baseAmount;
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -73,6 +75,9 @@ const expenseText = computed(() => formatMoney(totals.value.expense));
|
||||||
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
|
const visibleMonthlyEntries = computed(() => monthlyEntries.value.slice(0, visibleCount.value));
|
||||||
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
|
const hasMoreEntries = computed(() => visibleCount.value < monthlyEntries.value.length);
|
||||||
const pendingEntryCount = computed(() => store.pendingEntryCount(ledgerStore.currentLedgerId));
|
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 currentTheme = computed(() => ledgerTheme(ledgerStore.currentLedger?.theme));
|
||||||
|
|
||||||
const dailyTotals = computed(() => {
|
const dailyTotals = computed(() => {
|
||||||
|
|
@ -80,6 +85,7 @@ const dailyTotals = computed(() => {
|
||||||
for (const entry of monthlyEntries.value) {
|
for (const entry of monthlyEntries.value) {
|
||||||
const key = localDateKey(entry.occurredAt);
|
const key = localDateKey(entry.occurredAt);
|
||||||
const summary = summaries.get(key) ?? { income: 0, expense: 0 };
|
const summary = summaries.get(key) ?? { income: 0, expense: 0 };
|
||||||
|
if (entry.baseAmount === null) continue;
|
||||||
if (entry.type === "income") summary.income += entry.baseAmount;
|
if (entry.type === "income") summary.income += entry.baseAmount;
|
||||||
else summary.expense += entry.baseAmount;
|
else summary.expense += entry.baseAmount;
|
||||||
summaries.set(key, summary);
|
summaries.set(key, summary);
|
||||||
|
|
@ -310,6 +316,9 @@ async function shareLedger() {
|
||||||
<RefreshCw :size="15" :class="{ spinning: store.syncing }" />{{ store.syncing ? "同步中" : "立即同步" }}
|
<RefreshCw :size="15" :class="{ spinning: store.syncing }" />{{ store.syncing ? "同步中" : "立即同步" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
||||||
<div class="list-toolbar-copy">
|
<div class="list-toolbar-copy">
|
||||||
<div class="list-toolbar-heading">
|
<div class="list-toolbar-heading">
|
||||||
|
|
@ -358,9 +367,13 @@ async function shareLedger() {
|
||||||
</div>
|
</div>
|
||||||
<div class="entry-value">
|
<div class="entry-value">
|
||||||
<strong :class="entry.type">
|
<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>
|
</strong>
|
||||||
<span>{{ formatTime(entry.occurredAt) }}</span>
|
<span>{{ entry.currency === "CNY" || entry.baseAmount === null
|
||||||
|
? formatTime(entry.occurredAt)
|
||||||
|
: formatCurrencyAmount(entry.amount, entry.currency) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ async function createLedger() {
|
||||||
<button type="button" aria-label="关闭" title="关闭" @click="closeCreate"><X :size="20" /></button>
|
<button type="button" aria-label="关闭" title="关闭" @click="closeCreate"><X :size="20" /></button>
|
||||||
</header>
|
</header>
|
||||||
<label class="create-ledger-name"><span>账本名称</span><input v-model="name" maxlength="40" placeholder="例如:旅行计划" autocomplete="off" autofocus required /></label>
|
<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">
|
<fieldset class="create-ledger-colors">
|
||||||
<legend>账本主题</legend>
|
<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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,12 @@ const entries = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals = computed(() => summarize(entries.value));
|
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 dailyStats = computed(() => {
|
||||||
const groups = new Map<string, LedgerEntry[]>();
|
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 date = new Date(entry.occurredAt);
|
||||||
const key = toDateInput(date);
|
const key = toDateInput(date);
|
||||||
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
groups.set(key, [...(groups.get(key) ?? []), entry]);
|
||||||
|
|
@ -89,9 +91,9 @@ const maxDailyAmount = computed(() =>
|
||||||
|
|
||||||
const categoryStats = computed(() => {
|
const categoryStats = computed(() => {
|
||||||
const groups = new Map<string, { amount: number; count: number }>();
|
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 };
|
const current = groups.get(entry.categoryId) ?? { amount: 0, count: 0 };
|
||||||
current.amount += entry.baseAmount;
|
current.amount += entry.baseAmount!;
|
||||||
current.count += 1;
|
current.count += 1;
|
||||||
groups.set(entry.categoryId, current);
|
groups.set(entry.categoryId, current);
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +108,7 @@ const maxCategoryAmount = computed(() => Math.max(1, ...categoryStats.value.map(
|
||||||
|
|
||||||
const memberStats = computed(() => {
|
const memberStats = computed(() => {
|
||||||
const groups = new Map<string, LedgerEntry[]>();
|
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]) => ({
|
return Array.from(groups, ([userId, items]) => ({
|
||||||
userId,
|
userId,
|
||||||
name: ledgerStore.memberName(userId),
|
name: ledgerStore.memberName(userId),
|
||||||
|
|
@ -122,6 +124,7 @@ onMounted(async () => {
|
||||||
function summarize(items: LedgerEntry[]) {
|
function summarize(items: LedgerEntry[]) {
|
||||||
return items.reduce(
|
return items.reduce(
|
||||||
(result, entry) => {
|
(result, entry) => {
|
||||||
|
if (entry.baseAmount === null) return result;
|
||||||
if (entry.type === "income") result.income += entry.baseAmount;
|
if (entry.type === "income") result.income += entry.baseAmount;
|
||||||
else result.expense += entry.baseAmount;
|
else result.expense += entry.baseAmount;
|
||||||
return result;
|
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="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>
|
<div class="expense"><span><TrendingDown :size="17" />支出</span><strong>{{ money(totals.expense) }}</strong></div>
|
||||||
</section>
|
</section>
|
||||||
|
<p v-if="pendingConversionCount" class="stats-conversion-notice">
|
||||||
|
{{ pendingConversionCount }} 条外币账目等待换算,暂未计入人民币统计
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="stats-tabs" role="tablist" aria-label="统计维度">
|
<div class="stats-tabs" role="tablist" aria-label="统计维度">
|
||||||
<button type="button" :class="{ active: activeTab === 'flow' }" @click="activeTab = 'flow'">收支</button>
|
<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 .balance strong { font-size:24px; }
|
||||||
.stats-summary .income { color: #0d8b67; }
|
.stats-summary .income { color: #0d8b67; }
|
||||||
.stats-summary .expense { color: #d84c36; }
|
.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 { 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 { 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); }
|
.stats-tabs button.active { background: #fff; color: #087f72; box-shadow: 0 2px 7px rgba(28,52,47,.12); }
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ services:
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-cents}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-cents}
|
DATABASE_URL: postgres://${POSTGRES_USER:-cents}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-cents}
|
||||||
PUBLIC_ORIGIN: https://${DOMAIN}
|
PUBLIC_ORIGIN: https://${DOMAIN}
|
||||||
COOKIE_SECURE: "true"
|
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"
|
PORT: "3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
- 设计状态:待实现。
|
- 设计状态:首版已实现,后续按本文验收项继续补齐测试和运营能力。
|
||||||
- 记账本位币固定为 `CNY`。
|
- 记账本位币固定为 `CNY`。
|
||||||
- 本文描述多币种记账、汇率缓存、离线降级及人民币汇总的首版方案。
|
- 本文描述多币种记账、汇率缓存、离线降级及人民币汇总的首版方案。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,55 @@
|
||||||
export type EntryType = "expense" | "income";
|
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 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 = {
|
export const ledgerThemeAccents = {
|
||||||
jade: "#087f72",
|
jade: "#087f72",
|
||||||
|
|
@ -29,9 +76,12 @@ export type LedgerEntry = {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: CurrencyCode;
|
currency: CurrencyCode;
|
||||||
baseCurrency: CurrencyCode;
|
baseCurrency: CurrencyCode;
|
||||||
baseAmount: number;
|
baseAmount: number | null;
|
||||||
exchangeRate: string;
|
exchangeRate: string | null;
|
||||||
|
exchangeRateDate: string;
|
||||||
|
exchangeRateEffectiveDate: string | null;
|
||||||
exchangeRateSource: ExchangeRateSource;
|
exchangeRateSource: ExchangeRateSource;
|
||||||
|
conversionStatus: ConversionStatus;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
note: string;
|
note: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
|
|
|
||||||
|
|
@ -103,10 +103,13 @@ CREATE TABLE IF NOT EXISTS entries (
|
||||||
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
|
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
|
||||||
amount integer NOT NULL CHECK (amount > 0),
|
amount integer NOT NULL CHECK (amount > 0),
|
||||||
currency varchar(3) NOT NULL,
|
currency varchar(3) NOT NULL,
|
||||||
base_currency varchar(3) NOT NULL,
|
base_currency varchar(3) NOT NULL DEFAULT 'CNY',
|
||||||
base_amount integer NOT NULL CHECK (base_amount > 0),
|
base_amount integer CHECK (base_amount > 0),
|
||||||
exchange_rate varchar(32) NOT NULL,
|
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')),
|
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,
|
category_id varchar(100) NOT NULL,
|
||||||
note varchar(500) NOT NULL DEFAULT '',
|
note varchar(500) NOT NULL DEFAULT '',
|
||||||
occurred_at timestamptz NOT NULL,
|
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;
|
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;
|
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 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 (
|
CREATE TABLE IF NOT EXISTS entry_ledgers (
|
||||||
entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
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,
|
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
processed_at timestamptz NOT NULL DEFAULT now()
|
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() {
|
export async function initializeDatabase() {
|
||||||
|
|
@ -191,6 +275,12 @@ export async function initializeDatabase() {
|
||||||
SET unlinked_at = NULL, updated_at = now()
|
SET unlinked_at = NULL, updated_at = now()
|
||||||
WHERE entry_ledgers.unlinked_at IS NOT NULL`,
|
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");
|
await client.query("COMMIT");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
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 Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||||
import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js";
|
import { createUserWithPersonalLedger, PERSONAL_LEDGER_NAME } from "./accounts.js";
|
||||||
import { initializeDatabase, pool } from "./db.js";
|
import { initializeDatabase, pool } from "./db.js";
|
||||||
|
import {
|
||||||
|
enqueueConversion,
|
||||||
|
isCurrencyCode,
|
||||||
|
resolveExchangeRate,
|
||||||
|
startExchangeRateWorker,
|
||||||
|
} from "./exchange-rates.js";
|
||||||
import {
|
import {
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
createId,
|
createId,
|
||||||
|
|
@ -28,6 +40,7 @@ await server.register(cookie);
|
||||||
await server.register(helmet, { contentSecurityPolicy: false });
|
await server.register(helmet, { contentSecurityPolicy: false });
|
||||||
await server.register(rateLimit, { max: 120, timeWindow: "1 minute" });
|
await server.register(rateLimit, { max: 120, timeWindow: "1 minute" });
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
startExchangeRateWorker();
|
||||||
|
|
||||||
server.addHook("onRequest", async (request, reply) => {
|
server.addHook("onRequest", async (request, reply) => {
|
||||||
if (!["POST", "PATCH", "PUT", "DELETE"].includes(request.method)) return;
|
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/auth/")
|
||||||
|| request.url.startsWith("/api/invitations/")
|
|| request.url.startsWith("/api/invitations/")
|
||||||
|| request.url.startsWith("/api/ledger-invitations/")
|
|| request.url.startsWith("/api/ledger-invitations/")
|
||||||
|
|| request.url.startsWith("/api/exchange-rates/")
|
||||||
|| request.url.startsWith("/api/sync/")
|
|| request.url.startsWith("/api/sync/")
|
||||||
|| request.url === "/api/me"
|
|| request.url === "/api/me"
|
||||||
) {
|
) {
|
||||||
|
|
@ -115,8 +129,6 @@ function invitationStatus(invitation: { expiresAt: Date | string; acceptedAt: Da
|
||||||
return "valid";
|
return "valid";
|
||||||
}
|
}
|
||||||
|
|
||||||
const currencies = new Set(["CNY", "USD", "EUR", "JPY", "HKD"]);
|
|
||||||
|
|
||||||
function validLedgerTheme(value: unknown): value is LedgerThemeId {
|
function validLedgerTheme(value: unknown): value is LedgerThemeId {
|
||||||
return typeof value === "string" && Object.hasOwn(ledgerThemeAccents, value);
|
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));
|
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[] {
|
function validLedgerIds(value: unknown): value is string[] {
|
||||||
return Array.isArray(value) && value.length > 0 && value.length <= 20
|
return Array.isArray(value) && value.length > 0 && value.length <= 20
|
||||||
&& value.every((ledgerId) => typeof ledgerId === "string" && ledgerId.length > 0 && ledgerId.length <= 100)
|
&& 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)
|
&& validLedgerIds(entry.ledgerIds)
|
||||||
&& (entry.type === "expense" || entry.type === "income")
|
&& (entry.type === "expense" || entry.type === "income")
|
||||||
&& Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647
|
&& Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647
|
||||||
&& typeof entry.currency === "string" && currencies.has(entry.currency)
|
&& isCurrencyCode(entry.currency)
|
||||||
&& typeof entry.baseCurrency === "string" && currencies.has(entry.baseCurrency)
|
&& entry.baseCurrency === "CNY"
|
||||||
&& Number.isSafeInteger(entry.baseAmount) && entry.baseAmount! > 0 && entry.baseAmount! <= 2_147_483_647
|
&& (entry.baseAmount === null
|
||||||
&& typeof entry.exchangeRate === "string" && entry.exchangeRate.length > 0 && entry.exchangeRate.length <= 32
|
|| (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.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.categoryId === "string" && entry.categoryId.length > 0 && entry.categoryId.length <= 100
|
||||||
&& typeof entry.note === "string" && entry.note.length <= 500
|
&& typeof entry.note === "string" && entry.note.length <= 500
|
||||||
&& validDate(entry.occurredAt) && validDate(entry.createdAt) && validDate(entry.updatedAt)
|
&& 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";
|
const defaultCurrency = request.body.defaultCurrency ?? "CNY";
|
||||||
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称应为 1 至 40 个字符" });
|
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 (!/^#[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: "默认币种无效" });
|
return reply.code(400).send({ error: "默认币种无效" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,16 +356,31 @@ server.patch<{
|
||||||
}
|
}
|
||||||
const theme = request.body.theme ?? ledgerThemeFromColor(request.body.color);
|
const theme = request.body.theme ?? ledgerThemeFromColor(request.body.color);
|
||||||
const color = ledgerThemeAccents[theme];
|
const color = ledgerThemeAccents[theme];
|
||||||
|
const defaultCurrency = request.body.defaultCurrency ?? "CNY";
|
||||||
|
if (!isCurrencyCode(defaultCurrency)) return reply.code(400).send({ error: "默认币种无效" });
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE ledgers SET name = $1, color = $2, theme = $3, default_currency = $4, updated_at = now()
|
`UPDATE ledgers SET name = $1, color = $2, theme = $3, default_currency = $4, updated_at = now()
|
||||||
WHERE id = $5
|
WHERE id = $5
|
||||||
RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
|
RETURNING id, name, color, theme, default_currency AS "defaultCurrency",
|
||||||
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
|
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] };
|
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.
|
// Application invitations create accounts but never grant access to an existing ledger.
|
||||||
server.get<{ Params: { key: string } }>("/api/invitations/:key", async (request, reply) => {
|
server.get<{ Params: { key: string } }>("/api/invitations/:key", async (request, reply) => {
|
||||||
const user = await currentUser(request, reply);
|
const user = await currentUser(request, reply);
|
||||||
|
|
@ -515,14 +555,30 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
||||||
continue;
|
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",
|
`SELECT e.owner_id AS "ownerId",
|
||||||
(e.owner_id = $2 OR EXISTS (
|
(e.owner_id = $2 OR EXISTS (
|
||||||
SELECT 1 FROM entry_ledgers el
|
SELECT 1 FROM entry_ledgers el
|
||||||
JOIN ledger_members m ON m.ledger_id = el.ledger_id
|
JOIN ledger_members m ON m.ledger_id = el.ledger_id
|
||||||
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
|
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
|
||||||
AND m.user_id = $2 AND m.removed_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`,
|
FROM entries e WHERE e.id = $1`,
|
||||||
[operation.entityId, user.id],
|
[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 targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])];
|
||||||
|
|
||||||
const entry = operation.payload;
|
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(
|
const writeResult = await client.query(
|
||||||
`INSERT INTO entries (
|
`INSERT INTO entries (
|
||||||
id, owner_id, type, amount, currency, base_currency, base_amount,
|
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
|
created_by, updated_by, created_at, updated_at, deleted_at, version
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
$1, $2, $3, $4, $5, 'CNY', $6, $7, $8, $9, $10, $11, $12, $13,
|
||||||
$13, $14, $15, $16, $17, $18
|
$14, $15, $16, $17, $18, $19, $20
|
||||||
)
|
)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
type = EXCLUDED.type,
|
type = EXCLUDED.type,
|
||||||
|
|
@ -573,7 +659,10 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
||||||
base_currency = EXCLUDED.base_currency,
|
base_currency = EXCLUDED.base_currency,
|
||||||
base_amount = EXCLUDED.base_amount,
|
base_amount = EXCLUDED.base_amount,
|
||||||
exchange_rate = EXCLUDED.exchange_rate,
|
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,
|
exchange_rate_source = EXCLUDED.exchange_rate_source,
|
||||||
|
conversion_status = EXCLUDED.conversion_status,
|
||||||
category_id = EXCLUDED.category_id,
|
category_id = EXCLUDED.category_id,
|
||||||
note = EXCLUDED.note,
|
note = EXCLUDED.note,
|
||||||
occurred_at = EXCLUDED.occurred_at,
|
occurred_at = EXCLUDED.occurred_at,
|
||||||
|
|
@ -585,8 +674,9 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
||||||
WHERE entries.version < EXCLUDED.version
|
WHERE entries.version < EXCLUDED.version
|
||||||
OR (entries.version = EXCLUDED.version AND entries.updated_at <= EXCLUDED.updated_at)`,
|
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.id, user.id, entry.type, entry.amount, entry.currency,
|
||||||
entry.baseAmount, entry.exchangeRate, entry.exchangeRateSource, entry.categoryId,
|
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.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
|
||||||
entry.deletedAt, entry.version,
|
entry.deletedAt, entry.version,
|
||||||
],
|
],
|
||||||
|
|
@ -617,6 +707,14 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
||||||
[entry.id, targetLedgerIds],
|
[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("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);
|
||||||
await client.query(
|
await client.query(
|
||||||
"INSERT INTO entry_sync_operations (id, user_id) VALUES ($1, $2)",
|
"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",
|
) AS "ledgerIds",
|
||||||
e.type, e.amount,
|
e.type, e.amount,
|
||||||
e.currency, e.base_currency AS "baseCurrency", e.base_amount AS "baseAmount",
|
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.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
|
||||||
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
|
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
|
||||||
e.created_at AS "createdAt", e.updated_at AS "updatedAt",
|
e.created_at AS "createdAt", e.updated_at AS "updatedAt",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue