feat: add expense reimbursement workflow

This commit is contained in:
openclaw 2026-07-27 07:16:27 +08:00
parent ae31f2e567
commit dcc5a4b48f
16 changed files with 860 additions and 260 deletions

View File

@ -39,7 +39,6 @@ import {
Pill,
Puzzle,
ReceiptText,
RotateCcw,
School,
ShieldCheck,
ShoppingBag,
@ -211,7 +210,6 @@ export const categories: Record<EntryType, Category[]> = {
{ id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift },
{ id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: HandCoins },
{ id: "investment", label: "投资", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined },
{ id: "refund", label: "报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw },
{ id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote },
{ id: "received-red-packet", label: "红包", color: "#d94f45", tint: "#fff0ed", icon: WalletCards },
],

View File

@ -108,6 +108,22 @@ db.version(5).stores({
normalizeEntry(operation.payload);
});
});
db.version(6).stores({
entries: "id, ownerId, *ledgerIds, reimbursementOfEntryId, 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) => {
await transaction.table<LegacyEntry>("entries").toCollection().modify((entry) => {
entry.reimbursementOfEntryId ??= null;
});
await transaction.table<LegacyOperation>("syncOperations").toCollection().modify((operation) => {
operation.payload.reimbursementOfEntryId ??= null;
});
});
}
const databaseCache = new Map<string, CentsDatabase>();

View File

@ -10,6 +10,7 @@ type EntryInput = {
note: string;
occurredAt: string;
createdBy?: string;
reimbursementOfEntryId?: string | null;
};
const baseCurrency: CurrencyCode = "CNY";
@ -33,6 +34,7 @@ export function createEntry(input: EntryInput): LedgerEntry {
exchangeRateEffectiveDate: isCny ? exchangeRateDate : null,
exchangeRateSource: "system",
conversionStatus: isCny ? "exact" : "pending",
reimbursementOfEntryId: input.reimbursementOfEntryId ?? null,
categoryId: input.categoryId,
note: input.note,
occurredAt: input.occurredAt,

View File

@ -0,0 +1,34 @@
import type { LedgerEntry } from "@cents/domain";
export function isReimbursement(entry: LedgerEntry) {
return entry.type === "income" && Boolean(entry.reimbursementOfEntryId);
}
export function reimbursementsForExpense(entries: LedgerEntry[], expenseId: string) {
return entries.filter((entry) =>
entry.deletedAt === null
&& entry.reimbursementOfEntryId === expenseId,
);
}
export function reimbursedOriginalAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
const amount = reimbursementsForExpense(entries, expense.id)
.filter((entry) => entry.currency === expense.currency)
.reduce((sum, entry) => sum + entry.amount, 0);
return Math.min(expense.amount, amount);
}
export function reimbursedBaseAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
if (expense.baseAmount === null) return null;
const reimbursed = reimbursedOriginalAmount(expense, entries);
return Math.min(expense.baseAmount, Math.round(expense.baseAmount * reimbursed / expense.amount));
}
export function netOriginalAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
return Math.max(0, expense.amount - reimbursedOriginalAmount(expense, entries));
}
export function netBaseAmount(expense: LedgerEntry, entries: LedgerEntry[]) {
if (expense.baseAmount === null) return null;
return Math.max(0, expense.baseAmount - (reimbursedBaseAmount(expense, entries) ?? 0));
}

View File

@ -4,6 +4,7 @@ import { apiRequest } from "../data/api";
import { getUserDb } from "../data/db";
import { localDate } from "../data/entries";
import { createId } from "../data/ids";
import { reimbursementsForExpense, reimbursedOriginalAmount } from "../data/reimbursements";
import { useAuthStore } from "./auth";
import { useLedgerStore } from "./ledgers";
@ -186,6 +187,25 @@ export const useEntryStore = defineStore("entries", {
},
async addEntry(entry: LedgerEntry) {
const auth = useAuthStore();
if (entry.reimbursementOfEntryId) {
const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId);
if (
!expense
|| expense.deletedAt
|| expense.type !== "expense"
|| expense.ownerId !== auth.user?.id
) throw new Error("关联的原支出无效");
const sameLedgers = [...entry.ledgerIds].sort().join(",") === [...expense.ledgerIds].sort().join(",");
if (
entry.type !== "income"
|| entry.currency !== expense.currency
|| entry.categoryId !== expense.categoryId
|| !sameLedgers
) throw new Error("报销必须继承原支出的币种、分类和账本");
if (entry.amount > expense.amount - reimbursedOriginalAmount(expense, this.entries)) {
throw new Error("报销金额超过剩余可报销金额");
}
}
const db = await getUserDb(auth.user?.id ?? entry.ownerId);
const operation: SyncOperation = {
id: createId(),
@ -213,6 +233,37 @@ export const useEntryStore = defineStore("entries", {
const db = await getUserDb(auth.user?.id ?? entry.ownerId);
const existing = await db.entries.get(entry.id);
if (!existing || existing.deletedAt) throw new Error("Entry not found");
if (existing.reimbursementOfEntryId !== entry.reimbursementOfEntryId) {
throw new Error("不能修改报销关联");
}
if (entry.reimbursementOfEntryId) {
const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId);
if (!expense || expense.deletedAt || expense.ownerId !== auth.user?.id) {
throw new Error("关联的原支出无效");
}
const otherReimbursements = reimbursementsForExpense(this.entries, expense.id)
.filter((item) => item.id !== entry.id)
.reduce((sum, item) => sum + item.amount, 0);
if (
entry.type !== "income"
|| entry.currency !== expense.currency
|| entry.categoryId !== expense.categoryId
|| entry.amount + otherReimbursements > expense.amount
) throw new Error("报销金额超过剩余可报销金额");
} else if (existing.type === "expense") {
const reimbursed = reimbursedOriginalAmount(existing, this.entries);
const sameLedgers = [...existing.ledgerIds].sort().join(",") === [...entry.ledgerIds].sort().join(",");
if (
reimbursed > entry.amount
|| (reimbursed > 0 && (
existing.currency !== entry.currency
|| existing.categoryId !== entry.categoryId
|| !sameLedgers
))
) {
throw new Error("存在报销时不能缩小支出金额、改变分类、币种或账本");
}
}
const exchangeRateDate = localDate(entry.occurredAt);
const conversionChanged = existing.amount !== entry.amount
@ -333,6 +384,9 @@ export const useEntryStore = defineStore("entries", {
const db = await getUserDb(auth.user.id);
const existing = await db.entries.get(entryId);
if (!existing || existing.deletedAt) return;
if (existing.type === "expense" && reimbursementsForExpense(this.entries, entryId).length) {
throw new Error("存在报销记录,不能删除原支出");
}
const deletedAt = new Date().toISOString();
const deleted: LedgerEntry = {

View File

@ -321,6 +321,22 @@ input:focus-visible {
color: #ffe6dc;
}
.summary-expense-slide {
display: grid;
justify-items: end;
gap: 2px;
}
.summary-expense-slide small {
max-width: 100%;
overflow: hidden;
color: rgba(255, 255, 255, 0.62);
font-size: 10px;
line-height: 1.15;
text-align: right;
text-overflow: ellipsis;
}
.ledger-content {
position: relative;
height: calc(100vh - 180px);
@ -635,6 +651,15 @@ input:focus-visible {
font-variant-numeric: tabular-nums;
}
.entry-value .entry-original-amount {
margin: 0 0 1px;
color: #9aaaa5;
font-size: 11px;
line-height: 1.05;
text-decoration: line-through;
font-variant-numeric: tabular-nums;
}
.entry-value .expense {
color: #d84c36;
}

View File

@ -15,6 +15,7 @@ import {
CircleUserRound,
CloudOff,
NotebookText,
RotateCcw,
Trash2,
WalletCards,
X,
@ -29,6 +30,14 @@ import {
type Category,
} from "../data/categories";
import { ledgerTheme } from "../data/ledgers";
import { createEntry } from "../data/entries";
import {
isReimbursement,
netOriginalAmount,
reimbursementsForExpense,
reimbursedOriginalAmount,
} from "../data/reimbursements";
import { useAuthStore } from "../stores/auth";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
import CurrencyPicker from "../components/CurrencyPicker.vue";
@ -38,6 +47,7 @@ type CategorySheetMode = "parent" | "child";
const route = useRoute();
const router = useRouter();
const store = useEntryStore();
const authStore = useAuthStore();
const ledgerStore = useLedgerStore();
const entry = ref<LedgerEntry | null>(null);
const loading = ref(true);
@ -48,6 +58,11 @@ const categorySheetMode = ref<CategorySheetMode | null>(null);
const ledgerPickerOpen = ref(false);
const dateInput = ref<HTMLInputElement | null>(null);
const currencyPickerOpen = ref(false);
const reimbursementOpen = ref(false);
const reimbursementAmount = ref("");
const reimbursementOccurredAt = ref("");
const reimbursementNote = ref("");
const reimbursementSaving = ref(false);
const amount = ref("");
const currency = ref<CurrencyCode>("CNY");
@ -58,8 +73,10 @@ const note = ref("");
const occurredAt = ref("");
const entryType = computed<EntryType>(() => entry.value?.type ?? "expense");
const reimbursementEntry = computed(() => entry.value ? isReimbursement(entry.value) : false);
const categoryEntryType = computed<EntryType>(() => reimbursementEntry.value ? "expense" : entryType.value);
const typeOption = computed(() => entryTypes.find((type) => type.id === entryType.value)!);
const parentCategories = computed(() => categories[entryType.value]);
const parentCategories = computed(() => categories[categoryEntryType.value]);
const selectedParent = computed(() =>
parentCategories.value.find((category) => category.id === parentCategoryId.value),
);
@ -108,6 +125,29 @@ const conversionLabel = computed(() => {
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" ? " 最近汇率" : ""}`;
});
const sourceExpense = computed(() =>
entry.value?.reimbursementOfEntryId
? store.entries.find((item) => item.id === entry.value?.reimbursementOfEntryId) ?? null
: null,
);
const expenseReimbursements = computed(() =>
entry.value?.type === "expense" ? reimbursementsForExpense(store.entries, entry.value.id) : [],
);
const reimbursementLocked = computed(() => reimbursementEntry.value || expenseReimbursements.value.length > 0);
const reimbursedAmount = computed(() =>
entry.value?.type === "expense" ? reimbursedOriginalAmount(entry.value, store.entries) : 0,
);
const remainingReimbursement = computed(() =>
entry.value?.type === "expense" ? Math.max(0, entry.value.amount - reimbursedAmount.value) : 0,
);
const netExpenseAmount = computed(() =>
entry.value?.type === "expense" ? netOriginalAmount(entry.value, store.entries) : 0,
);
const canRecordReimbursement = computed(() =>
entry.value?.type === "expense"
&& entry.value.ownerId === authStore.user?.id
&& remainingReimbursement.value > 0,
);
onMounted(async () => {
await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]);
@ -124,7 +164,7 @@ function hydrate(value: LedgerEntry) {
ledgerIds.value = [...new Set([...value.ledgerIds, ledgerStore.personalLedger?.id].filter((id): id is string => Boolean(id)))];
note.value = value.note;
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
const path = findCategoryPath(value.type, value.categoryId);
const path = findCategoryPath(value.reimbursementOfEntryId ? "expense" : value.type, value.categoryId);
parentCategoryId.value = path[0]?.id ?? "";
childCategoryId.value = path[1]?.id ?? "";
}
@ -160,6 +200,7 @@ function chooseChild(category: Category) {
}
function toggleLedger(ledgerId: string) {
if (reimbursementEntry.value) return;
if (ledgerId === ledgerStore.personalLedger?.id) return;
if (ledgerIds.value.includes(ledgerId)) {
if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId);
@ -168,6 +209,78 @@ function toggleLedger(ledgerId: string) {
ledgerIds.value = [...ledgerIds.value, ledgerId];
}
function formatEntryAmount(value: number, code = currency.value) {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: code,
currencyDisplay: "narrowSymbol",
minimumFractionDigits: currencyMinorUnits(code),
maximumFractionDigits: currencyMinorUnits(code),
}).format(value / 10 ** currencyMinorUnits(code));
}
function formatShortDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function openReimbursement() {
if (!entry.value || !canRecordReimbursement.value) return;
const minorUnits = currencyMinorUnits(entry.value.currency);
reimbursementAmount.value = (remainingReimbursement.value / 10 ** minorUnits).toFixed(minorUnits);
reimbursementOccurredAt.value = toLocalDateTime(new Date());
reimbursementNote.value = "";
errorMessage.value = "";
reimbursementOpen.value = true;
}
async function saveReimbursement() {
if (!entry.value || reimbursementSaving.value) return;
const numericAmount = Number(reimbursementAmount.value.replaceAll(",", ""));
const minorAmount = Math.round(numericAmount * 10 ** currencyMinorUnits(entry.value.currency));
const occurredDate = new Date(reimbursementOccurredAt.value);
if (
!Number.isFinite(numericAmount)
|| minorAmount <= 0
|| minorAmount > remainingReimbursement.value
) {
errorMessage.value = "报销金额应大于 0 且不超过剩余可报销金额";
return;
}
if (Number.isNaN(occurredDate.getTime())) {
errorMessage.value = "请选择报销到账时间";
return;
}
reimbursementSaving.value = true;
errorMessage.value = "";
try {
await store.addEntry(createEntry({
ledgerIds: [...entry.value.ledgerIds],
type: "income",
amount: minorAmount,
currency: entry.value.currency,
categoryId: entry.value.categoryId,
note: reimbursementNote.value.trim() || `${entry.value.note || selectedCategory.value?.label || "支出"}报销`,
occurredAt: occurredDate.toISOString(),
createdBy: authStore.user?.id,
reimbursementOfEntryId: entry.value.id,
}));
reimbursementOpen.value = false;
savedToast.value = true;
window.setTimeout(() => (savedToast.value = false), 1600);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "记录报销失败";
} finally {
reimbursementSaving.value = false;
}
}
async function saveEntry() {
if (!entry.value || saving.value) return;
const numericAmount = Number(amount.value.replaceAll(",", ""));
@ -190,7 +303,9 @@ async function saveEntry() {
amount: minorAmount,
currency: currency.value,
ledgerIds: [...ledgerIds.value],
categoryId: childCategoryId.value || parentCategoryId.value || entryType.value,
categoryId: reimbursementEntry.value
? entry.value.categoryId
: childCategoryId.value || parentCategoryId.value || entryType.value,
note: note.value.trim(),
occurredAt: occurredDate.toISOString(),
});
@ -207,11 +322,19 @@ async function saveEntry() {
async function deleteEntry() {
if (!entry.value) return;
if (entry.value.type === "expense" && expenseReimbursements.value.length) {
errorMessage.value = "存在报销记录,请先删除关联的报销到账";
return;
}
const count = ledgerIds.value.length;
const message = `确定从这笔账目关联的全部账本中删除吗?\n\n共 ${count} 个账本,删除后无法恢复。`;
if (!window.confirm(message)) return;
await store.deleteEntry(entry.value.id);
await router.back();
try {
await store.deleteEntry(entry.value.id);
await router.back();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "删除失败";
}
}
</script>
@ -237,15 +360,16 @@ async function deleteEntry() {
<div
class="entry-detail-category-mark"
:style="{
color: selectedCategory?.color ?? typeOption.color,
background: selectedCategory?.tint ?? typeOption.tint,
color: reimbursementEntry ? '#287c70' : selectedCategory?.color ?? typeOption.color,
background: reimbursementEntry ? '#e8f6f2' : selectedCategory?.tint ?? typeOption.tint,
}"
>
<component :is="selectedCategory?.icon ?? typeOption.icon" :size="27" />
<RotateCcw v-if="reimbursementEntry" :size="27" />
<component v-else :is="selectedCategory?.icon ?? typeOption.icon" :size="27" />
</div>
<span class="entry-type-label">{{ typeOption.label }}</span>
<span class="entry-type-label">{{ reimbursementEntry ? "报销到账" : typeOption.label }}</span>
<label class="entry-amount-input">
<button type="button" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="currencyPickerOpen = true">
<button type="button" :disabled="reimbursementLocked" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="currencyPickerOpen = true">
{{ currencySymbol }}
</button>
<input v-model="amount" inputmode="decimal" aria-label="金额" />
@ -254,7 +378,7 @@ async function deleteEntry() {
</section>
<section class="entry-edit-fields" aria-label="编辑条目">
<button class="entry-edit-row" type="button" @click="categorySheetMode = 'parent'">
<button class="entry-edit-row" type="button" :disabled="reimbursementLocked" @click="categorySheetMode = 'parent'">
<span class="entry-field-icon category"><WalletCards :size="19" /></span>
<span class="entry-field-copy"><small>大类</small><strong>{{ selectedParent?.label ?? "请选择" }}</strong></span>
<ChevronRight :size="19" />
@ -263,7 +387,7 @@ async function deleteEntry() {
<button
class="entry-edit-row"
type="button"
:disabled="!childCategories.length"
:disabled="reimbursementLocked || !childCategories.length"
@click="categorySheetMode = 'child'"
>
<span class="entry-field-icon subcategory">
@ -291,7 +415,7 @@ async function deleteEntry() {
tabindex="-1"
/>
<button class="entry-edit-row ledger-associations" type="button" @click="ledgerPickerOpen = true">
<button class="entry-edit-row ledger-associations" type="button" :disabled="reimbursementLocked" @click="ledgerPickerOpen = true">
<span class="entry-field-icon ledger"><BookOpen :size="19" /></span>
<span class="entry-field-copy"><small>关联账本 · {{ ledgerIds.length }} </small><strong>{{ ledgerLabel }}</strong></span>
<ChevronRight :size="18" />
@ -311,6 +435,39 @@ async function deleteEntry() {
</label>
</section>
<section v-if="entry.type === 'expense'" class="entry-reimbursement-card">
<header>
<span class="entry-field-icon reimbursement"><RotateCcw :size="19" /></span>
<div><strong>报销</strong><small>{{ expenseReimbursements.length ? `${expenseReimbursements.length} 笔到账` : "尚未报销" }}</small></div>
</header>
<div v-if="expenseReimbursements.length" class="entry-reimbursement-math">
<span><small>原支出</small><strong>{{ formatEntryAmount(entry.amount, entry.currency) }}</strong></span>
<span><small>已报销</small><strong> {{ formatEntryAmount(reimbursedAmount, entry.currency) }}</strong></span>
<span><small>净支出</small><strong>{{ formatEntryAmount(netExpenseAmount, entry.currency) }}</strong></span>
</div>
<div v-if="expenseReimbursements.length" class="entry-reimbursement-list">
<RouterLink v-for="item in expenseReimbursements" :key="item.id" :to="`/entries/${item.id}`">
<span><RotateCcw :size="15" />{{ formatShortDate(item.occurredAt) }}</span>
<strong>+ {{ formatEntryAmount(item.amount, item.currency) }}</strong>
<ChevronRight :size="16" />
</RouterLink>
</div>
<button v-if="canRecordReimbursement" class="entry-reimbursement-action" type="button" @click="openReimbursement">
<RotateCcw :size="17" />记录报销
</button>
<p v-else-if="remainingReimbursement === 0">已全额报销</p>
</section>
<RouterLink v-else-if="reimbursementEntry && sourceExpense" class="entry-reimbursement-card source" :to="`/entries/${sourceExpense.id}`">
<span class="entry-field-icon reimbursement"><RotateCcw :size="19" /></span>
<div>
<small>关联原支出</small>
<strong>{{ sourceExpense.note || findCategory(sourceExpense.categoryId)?.label || "支出" }}</strong>
</div>
<span>{{ formatEntryAmount(sourceExpense.amount, sourceExpense.currency) }}</span>
<ChevronRight :size="17" />
</RouterLink>
<p class="entry-created-at">
<span v-if="store.isEntryPending(entry.id)" class="entry-sync-pending" role="img" aria-label="未同步云端" title="未同步云端"><CloudOff :size="13" /></span>
{{ ledgerStore.memberName(entry.createdBy) }}创建 · {{ createdAtLabel }}
@ -399,6 +556,29 @@ async function deleteEntry() {
</section>
</div>
<Transition name="sheet">
<div v-if="reimbursementOpen" class="entry-category-layer reimbursement-layer">
<button class="entry-category-scrim" type="button" aria-label="关闭记录报销" @click="reimbursementOpen = false"></button>
<form class="reimbursement-sheet" @submit.prevent="saveReimbursement">
<div class="entry-sheet-handle"></div>
<header>
<div><strong>记录报销</strong><small>剩余 {{ entry ? formatEntryAmount(remainingReimbursement, entry.currency) : "" }}</small></div>
<button type="button" aria-label="关闭" title="关闭" @click="reimbursementOpen = false"><X :size="20" /></button>
</header>
<label class="reimbursement-amount">
<span>{{ entry ? currencies[entry.currency].symbol : "¥" }}</span>
<input v-model="reimbursementAmount" inputmode="decimal" aria-label="本次报销金额" autofocus />
</label>
<label><span>到账时间</span><input v-model="reimbursementOccurredAt" type="datetime-local" /></label>
<label><span>备注</span><input v-model="reimbursementNote" maxlength="120" placeholder="补充说明" /></label>
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
<button class="reimbursement-submit" type="submit" :disabled="reimbursementSaving">
<Check :size="18" />{{ reimbursementSaving ? "保存中" : "确认报销" }}
</button>
</form>
</div>
</Transition>
<Transition name="toast">
<div v-if="savedToast" class="entry-detail-toast" role="status"><Check :size="17" />修改已保存</div>
</Transition>
@ -876,6 +1056,254 @@ async function deleteEntry() {
white-space: nowrap;
}
.entry-field-icon.reimbursement {
background: #e8f6f2;
color: #287c70;
}
.entry-reimbursement-card {
margin-top: 12px;
border-top: 1px solid #dce7e4;
border-bottom: 1px solid #dce7e4;
padding: 14px 16px;
background: #ffffff;
}
.entry-reimbursement-card > header {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.entry-reimbursement-card > header div {
display: grid;
gap: 2px;
}
.entry-reimbursement-card > header small,
.entry-reimbursement-card.source small {
color: #7b8884;
font-size: 11px;
}
.entry-reimbursement-math {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 13px;
border: 1px solid #e1ebe8;
border-radius: 8px;
background: #f8fbfa;
}
.entry-reimbursement-math span {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 8px;
text-align: center;
}
.entry-reimbursement-math span + span {
border-left: 1px solid #e1ebe8;
}
.entry-reimbursement-math small {
color: #7b8884;
font-size: 10px;
}
.entry-reimbursement-math strong {
overflow: hidden;
color: #31504a;
font-size: 12px;
text-overflow: ellipsis;
}
.entry-reimbursement-math span:nth-child(2) strong {
color: #287c70;
}
.entry-reimbursement-list {
margin-top: 8px;
}
.entry-reimbursement-list a {
min-height: 42px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto 18px;
align-items: center;
gap: 7px;
border-bottom: 1px solid #edf2f0;
color: #536762;
text-decoration: none;
}
.entry-reimbursement-list a span {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.entry-reimbursement-list a strong {
color: #287c70;
font-size: 12px;
}
.entry-reimbursement-action {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 12px;
border: 1px solid #9bcfc3;
border-radius: 8px;
background: #edf8f5;
color: #176d61;
font-weight: 720;
}
.entry-reimbursement-card > p {
margin: 12px 0 0;
color: #287c70;
text-align: center;
font-size: 12px;
}
.entry-reimbursement-card.source {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto 18px;
align-items: center;
gap: 10px;
color: #27342f;
text-decoration: none;
}
.entry-reimbursement-card.source > div {
min-width: 0;
display: grid;
gap: 2px;
}
.entry-reimbursement-card.source > span:not(.entry-field-icon) {
color: #687873;
font-size: 12px;
}
.reimbursement-sheet {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
display: grid;
gap: 13px;
border-radius: 8px 8px 0 0;
padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
background: #ffffff;
box-shadow: 0 -14px 34px rgba(22, 50, 44, 0.16);
}
.reimbursement-sheet > header {
display: flex;
align-items: center;
justify-content: space-between;
}
.reimbursement-sheet > header div {
display: grid;
gap: 2px;
}
.reimbursement-sheet > header small {
color: #7b8884;
font-size: 11px;
}
.reimbursement-sheet > header button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: #f0f5f3;
color: #536762;
}
.reimbursement-sheet > label {
display: grid;
gap: 6px;
color: #687873;
font-size: 11px;
}
.reimbursement-sheet > label input {
width: 100%;
height: 44px;
border: 1px solid #d8e5e1;
border-radius: 8px;
padding: 0 11px;
outline: none;
background: #fbfdfc;
color: #27342f;
font-size: 14px;
}
.reimbursement-sheet > label input:focus {
border-color: #4b9e8e;
box-shadow: 0 0 0 3px rgba(75, 158, 142, 0.12);
}
.reimbursement-sheet .reimbursement-amount {
position: relative;
display: flex;
align-items: center;
color: #287c70;
}
.reimbursement-amount > span {
position: absolute;
left: 12px;
z-index: 1;
font-size: 25px;
font-weight: 720;
}
.reimbursement-sheet .reimbursement-amount input {
height: 62px;
padding-left: 44px;
color: #287c70;
font-size: 30px;
font-weight: 760;
}
.reimbursement-sheet > p {
margin: -4px 0;
color: #c94635;
font-size: 12px;
}
.reimbursement-submit {
min-height: 46px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 0;
border-radius: 8px;
background: #287c70;
color: #ffffff;
font-weight: 760;
}
.reimbursement-submit:disabled {
opacity: 0.55;
}
.entry-detail-toast {
position: absolute;
right: 50%;
@ -919,7 +1347,9 @@ async function deleteEntry() {
}
.sheet-enter-active .entry-category-sheet,
.sheet-leave-active .entry-category-sheet {
.sheet-leave-active .entry-category-sheet,
.sheet-enter-active .reimbursement-sheet,
.sheet-leave-active .reimbursement-sheet {
transition: transform 220ms ease;
}
@ -929,7 +1359,9 @@ async function deleteEntry() {
}
.sheet-enter-from .entry-category-sheet,
.sheet-leave-to .entry-category-sheet {
.sheet-leave-to .entry-category-sheet,
.sheet-enter-from .reimbursement-sheet,
.sheet-leave-to .reimbursement-sheet {
transform: translateY(100%);
}

View File

@ -15,6 +15,7 @@ import {
Filter,
MoreHorizontal,
RefreshCw,
RotateCcw,
Settings,
Share2,
X,
@ -26,6 +27,13 @@ import { apiRequest, ApiError } from "../data/api";
import { categories, entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { ledgerIconComponent } from "../data/ledger-icons";
import { ledgerTheme } from "../data/ledgers";
import {
isReimbursement,
netBaseAmount,
netOriginalAmount,
reimbursedBaseAmount,
reimbursedOriginalAmount,
} from "../data/reimbursements";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
@ -152,9 +160,9 @@ const dailyTotals = computed(() => {
for (const entry of monthlyEntries.value) {
const key = localDateKey(entry.occurredAt);
const summary = summaries.get(key) ?? { income: 0, expense: 0 };
if (entry.baseAmount === null) continue;
if (isReimbursement(entry) || entry.baseAmount === null) continue;
if (entry.type === "income") summary.income += entry.baseAmount;
else summary.expense += entry.baseAmount;
else summary.expense += netBaseAmount(entry, store.entries) ?? 0;
summaries.set(key, summary);
}
return summaries;
@ -187,23 +195,38 @@ const headerEntries = computed(() => {
const headerSummaryLabel = computed(() => ledgerStore.currentLedger?.summaryRange === "ledger" ? "账本结余" : "当年结余");
const summaryCurrencySlides = computed(() => {
const summaries = new Map<LedgerEntry["currency"], { income: number; expense: number }>();
const summaries = new Map<LedgerEntry["currency"], {
income: number;
grossExpense: number;
reimbursement: number;
}>();
for (const entry of headerEntries.value) {
if (isReimbursement(entry)) continue;
const currency = showBaseCurrency.value ? "CNY" : entry.currency;
const amount = showBaseCurrency.value ? entry.baseAmount : entry.amount;
if (amount === null) continue;
const summary = summaries.get(currency) ?? { income: 0, expense: 0 };
const summary = summaries.get(currency) ?? { income: 0, grossExpense: 0, reimbursement: 0 };
if (entry.type === "income") summary.income += amount;
else summary.expense += amount;
else {
summary.grossExpense += amount;
summary.reimbursement += showBaseCurrency.value
? reimbursedBaseAmount(entry, store.entries) ?? 0
: reimbursedOriginalAmount(entry, store.entries);
}
summaries.set(currency, summary);
}
if (!summaries.size) summaries.set("CNY", { income: 0, expense: 0 });
return [...summaries.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([currency, summary]) => ({
currency,
balance: formatCurrencyAmount(summary.income - summary.expense, currency),
income: formatCurrencyAmount(summary.income, currency),
expense: formatCurrencyAmount(summary.expense, currency),
}));
if (!summaries.size) summaries.set("CNY", { income: 0, grossExpense: 0, reimbursement: 0 });
return [...summaries.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([currency, summary]) => {
const expense = Math.max(0, summary.grossExpense - summary.reimbursement);
return {
currency,
balance: formatCurrencyAmount(summary.income - expense, currency),
income: formatCurrencyAmount(summary.income, currency),
expense: formatCurrencyAmount(expense, currency),
reimbursement: formatCurrencyAmount(summary.reimbursement, currency, false),
hasReimbursement: summary.reimbursement > 0,
};
});
});
watch(summaryCurrencySlides, (slides) => {
@ -218,15 +241,17 @@ function summarizeEntries(entries: LedgerEntry[]) {
const balanceByCurrency = new Map<LedgerEntry["currency"], number>();
for (const entry of entries) {
if (isReimbursement(entry)) continue;
if (showBaseCurrency.value) {
if (entry.baseAmount === null) continue;
if (entry.type === "income") income += entry.baseAmount;
else expense += entry.baseAmount;
else expense += netBaseAmount(entry, store.entries) ?? 0;
continue;
}
const totalsByCurrency = entry.type === "income" ? incomeByCurrency : expenseByCurrency;
totalsByCurrency.set(entry.currency, (totalsByCurrency.get(entry.currency) ?? 0) + entry.amount);
balanceByCurrency.set(entry.currency, (balanceByCurrency.get(entry.currency) ?? 0) + (entry.type === "income" ? entry.amount : -entry.amount));
const amount = entry.type === "expense" ? netOriginalAmount(entry, store.entries) : entry.amount;
totalsByCurrency.set(entry.currency, (totalsByCurrency.get(entry.currency) ?? 0) + amount);
balanceByCurrency.set(entry.currency, (balanceByCurrency.get(entry.currency) ?? 0) + (entry.type === "income" ? amount : -amount));
}
return {
@ -635,14 +660,37 @@ function formatTime(value: string) {
}
function categoryLabel(entry: LedgerEntry) {
if (isReimbursement(entry)) {
const source = store.entries.find((item) => item.id === entry.reimbursementOfEntryId);
const sourcePath = source ? findCategoryPath("expense", source.categoryId) : [];
return `报销${sourcePath.length ? ` · ${sourcePath.at(-1)!.label}` : ""}`;
}
const path = findCategoryPath(entry.type, entry.categoryId);
return path.map((item) => item.label).join(" · ") || (entry.type === "expense" ? "支出" : "收入");
}
function categoryIcon(entry: LedgerEntry) {
if (isReimbursement(entry)) return RotateCcw;
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((type) => type.id === entry.type)?.icon ?? BookOpen;
}
function entryHasReimbursement(entry: LedgerEntry) {
return entry.type === "expense" && reimbursedOriginalAmount(entry, store.entries) > 0;
}
function entryDisplayAmount(entry: LedgerEntry, net = false) {
if (showBaseCurrency.value && entry.baseAmount !== null) {
const amount = net && entry.type === "expense"
? netBaseAmount(entry, store.entries) ?? entry.baseAmount
: entry.baseAmount;
return `¥${formatMoney(amount)}`;
}
const amount = net && entry.type === "expense"
? netOriginalAmount(entry, store.entries)
: entry.amount;
return formatCurrencyAmount(amount, entry.currency);
}
function openParticipantSheet() {
participantOpen.value = true;
void ledgerStore.loadCurrentMembers();
@ -783,7 +831,10 @@ async function shareLedger() {
<span>支出</span>
<div class="summary-value-window">
<div class="summary-value-track" :style="{ '--summary-slides': summaryCurrencySlides.length, transform: `translate3d(-${summaryCurrencyIndex * (100 / summaryCurrencySlides.length)}%, 0, 0)` }">
<strong v-for="slide in summaryCurrencySlides" :key="slide.currency" class="summary-value-slide" :class="{ compact: slide.expense.length > 10 }">{{ slide.expense }}</strong>
<div v-for="slide in summaryCurrencySlides" :key="slide.currency" class="summary-value-slide summary-expense-slide">
<strong :class="{ compact: slide.expense.length > 10 }">{{ slide.expense }}</strong>
<small v-if="slide.hasReimbursement">已减去报销金额 {{ slide.reimbursement }}</small>
</div>
</div>
</div>
</div>
@ -932,8 +983,8 @@ async function shareLedger() {
<div
class="entry-category-icon"
:style="{
color: findCategory(entry.categoryId)?.color ?? (entry.type === 'expense' ? '#ef5b3f' : '#16966f'),
background: findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'),
color: isReimbursement(entry) ? '#287c70' : findCategory(entry.categoryId)?.color ?? (entry.type === 'expense' ? '#ef5b3f' : '#16966f'),
background: isReimbursement(entry) ? '#e8f6f2' : findCategory(entry.categoryId)?.tint ?? (entry.type === 'expense' ? '#fff0ec' : '#e9faf4'),
}"
>
<component :is="categoryIcon(entry)" :size="21" />
@ -947,11 +998,8 @@ async function shareLedger() {
</div>
</div>
<div class="entry-value">
<strong :class="entry.type">
{{ entry.type === "expense" ? "" : "+" }}{{ showBaseCurrency && entry.baseAmount !== null
? `¥${formatMoney(entry.baseAmount)}`
: formatCurrencyAmount(entry.amount, entry.currency) }}
</strong>
<span v-if="entryHasReimbursement(entry)" class="entry-original-amount">{{ entryDisplayAmount(entry) }}</span>
<strong :class="entry.type">{{ entry.type === "expense" ? "" : "+" }}{{ entryDisplayAmount(entry, true) }}</strong>
<span>{{ formatTime(entry.occurredAt) }}</span>
</div>
</RouterLink>

View File

@ -1,12 +1,13 @@
<script setup lang="ts">
import { formatCurrencyAmount, type LedgerEntry } from "@cents/domain";
import { ArrowLeft, Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, Filter, Minus, ReceiptText, WalletCards, X } from "@lucide/vue";
import { ArrowLeft, Check, ChevronDown, ChevronLeft, ChevronRight, CircleUserRound, Filter, Minus, ReceiptText, RotateCcw, WalletCards, X } from "@lucide/vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { categories, entryTypes, findCategory, findCategoryPath } from "../data/categories";
import { ledgerIconComponent } from "../data/ledger-icons";
import { ledgerTheme } from "../data/ledgers";
import { isReimbursement } from "../data/reimbursements";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
@ -292,11 +293,17 @@ onMounted(async () => {
});
function entryCategoryLabel(entry: LedgerEntry) {
if (isReimbursement(entry)) {
const source = entryStore.entries.find((item) => item.id === entry.reimbursementOfEntryId);
const label = source ? findCategoryPath("expense", source.categoryId).at(-1)?.label : "";
return `报销${label ? ` · ${label}` : ""}`;
}
return findCategoryPath(entry.type, entry.categoryId).map((item) => item.label).join(" · ")
|| (entry.type === "expense" ? "支出" : "收入");
}
function entryIcon(entry: LedgerEntry) {
if (isReimbursement(entry)) return RotateCcw;
return findCategory(entry.categoryId)?.icon ?? entryTypes.find((item) => item.id === entry.type)?.icon ?? ReceiptText;
}
@ -352,7 +359,7 @@ function localDateKey(value: string) {
<section v-for="group in groupedEntries" :key="group.date" class="stats-entries-day">
<header><strong>{{ dayLabel(group.date) }}</strong><span>{{ group.entries.length }} </span></header>
<RouterLink v-for="entry in group.entries" :key="entry.id" :to="`/entries/${entry.id}`" class="stats-entry-row">
<span class="stats-entry-icon" :style="{ color: findCategory(entry.categoryId)?.color ?? '#087f72', background: findCategory(entry.categoryId)?.tint ?? '#e9f7f3' }"><component :is="entryIcon(entry)" :size="19" /></span>
<span class="stats-entry-icon" :style="{ color: isReimbursement(entry) ? '#287c70' : findCategory(entry.categoryId)?.color ?? '#087f72', background: isReimbursement(entry) ? '#e8f6f2' : findCategory(entry.categoryId)?.tint ?? '#e9f7f3' }"><component :is="entryIcon(entry)" :size="19" /></span>
<span class="stats-entry-copy"><strong>{{ entry.note || entryCategoryLabel(entry) }}</strong><small><CircleUserRound :size="12" />{{ entryCategoryLabel(entry) }}</small></span>
<strong class="stats-entry-amount" :class="entry.type">{{ entryAmount(entry) }}</strong>
</RouterLink>

View File

@ -6,6 +6,7 @@ import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
import QuickEntryHost from "../components/QuickEntryHost.vue";
import { entryTypes, findCategoryPath } from "../data/categories";
import { ledgerTheme } from "../data/ledgers";
import { isReimbursement, netBaseAmount } from "../data/reimbursements";
import { useEntryStore } from "../stores/entries";
import { useLedgerStore } from "../stores/ledgers";
@ -174,8 +175,9 @@ const entries = computed(() => {
});
const totals = computed(() => summarize(entries.value));
const convertedEntries = computed(() => entries.value.filter((entry) => entry.baseAmount !== null));
const pendingConversionCount = computed(() => entries.value.length - convertedEntries.value.length);
const statsEntries = computed(() => entries.value.filter((entry) => !isReimbursement(entry)));
const convertedEntries = computed(() => statsEntries.value.filter((entry) => entry.baseAmount !== null));
const pendingConversionCount = computed(() => statsEntries.value.length - convertedEntries.value.length);
const calendarCursorMonth = ref(selectedMonth.value);
const calendarCursorYear = ref(selectedYear.value);
@ -358,6 +360,7 @@ const expandedCategoryKeys = ref(new Set<string>());
const categoryTree = computed(() => {
const roots = new Map<string, CategorySummaryNode>();
for (const entry of convertedEntries.value) {
const amount = entry.type === "expense" ? netBaseAmount(entry, entryStore.entries) ?? 0 : entry.baseAmount!;
const type = entryTypes.find((item) => item.id === entry.type);
if (!type) continue;
const root = roots.get(entry.type) ?? {
@ -370,7 +373,7 @@ const categoryTree = computed(() => {
count: 0,
children: [],
};
root.amount += entry.baseAmount!;
root.amount += amount;
root.count += 1;
const path = findCategoryPath(entry.type, entry.categoryId);
@ -395,7 +398,7 @@ const categoryTree = computed(() => {
};
root.children.push(group);
}
group.amount += entry.baseAmount!;
group.amount += amount;
group.count += 1;
const leafCategory = path[1];
@ -414,7 +417,7 @@ const categoryTree = computed(() => {
};
group.children.push(leaf);
}
leaf.amount += entry.baseAmount!;
leaf.amount += amount;
leaf.count += 1;
}
roots.set(entry.type, root);
@ -471,8 +474,9 @@ function summarize(items: LedgerEntry[]) {
return items.reduce(
(result, entry) => {
if (entry.baseAmount === null) return result;
if (isReimbursement(entry)) return result;
if (entry.type === "income") result.income += entry.baseAmount;
else result.expense += entry.baseAmount;
else result.expense += netBaseAmount(entry, entryStore.entries) ?? 0;
return result;
},
{ income: 0, expense: 0 },

View File

@ -1,228 +1,84 @@
# 二期:报销与退款
# 报销
## 背景
## 产品定义
家庭账本里有一类收入并不代表真实新增收入,而是对过去支出的抵消,例如:
报销是对一笔既有支出的冲减,不属于普通收入。
- 公司报销餐费、交通费、差旅费。
- 商家退款、退货退款、平台补贴返还。
- AA 或代付后,其他成员转回款项。
首版只记录普通收入和支出。二期需要支持“收入关联原支出”,让流水、统计和净支出更接近真实消费。
## 目标
- 报销和退款在录入时仍作为收入类型保存。
- 输入金额后,系统可以推荐匹配的历史支出。
- 用户可以把一笔报销或退款关联到一笔或多笔支出。
- 流水中能看出某笔收入是报销或退款,以及它关联了哪些支出。
- 月度统计能区分总支出、报销/退款抵扣、净支出。
## 非目标
- 不做企业报销流程审批。
- 不做发票、附件、小票 OCR。
- 不做复杂应收应付和债务结算。
- 不自动修改原支出金额。
- 不要求系统自动判断 100% 正确,必须允许用户手动选择或取消关联。
## 概念定义
### 普通收入
真实增加可支配金额的收入,例如工资、奖金、利息。
### 报销
对已发生支出的补偿。通常来自公司、组织或共同生活成员。
示例:用户先记一笔 `交通 120 元`,之后收到公司报销 `120 元`
### 退款
商家、平台或交易对方退回的金额。通常对应购物、服务、押金等历史支出。
示例:用户先记一笔 `购物 299 元`,之后退货收到 `299 元`
### 关联支出
被报销或退款收入抵消的原始支出记录。
- 报销只能从支出详情发起,不进入快速记账的收入分类。
- 一笔报销只关联一笔原支出。
- 一笔支出可以分多次报销,也可以只报销一部分。
- 累计报销金额不能超过原支出金额。
- 首版不实现报销单、审批、发票和一笔到账分摊多笔支出。
## 录入流程
### 快速记账入口
1. 用户进入一笔支出的详情。
2. 点击“记录报销”。
3. 输入本次报销金额、到账时间和备注。
4. 币种、分类和账本自动继承原支出,不能单独修改。
5. 保存后生成一条独立的报销到账流水。
收入类型下增加收入子类型:
只有原支出的所有者可以记录报销。共享账本成员可以查看报销及净支出,但不能替其他用户操作报销。
- 普通收入
- 报销
- 退款
## 数据模型
用户选择 `报销``退款`
`entries` 增加自关联字段:
1. 输入收入金额。
2. 系统根据金额、时间、分类、备注、付款人推荐历史支出。
3. 用户选择一笔或多笔支出。
4. 用户确认保存。
5. 新收入记录保存,并建立与原支出的关联。
```text
reimbursement_of_entry_id
```
### 推荐匹配
- 普通收入:`type = income`,关联字段为空。
- 报销到账:`type = income`,关联字段指向原支出。
- 原支出:`type = expense`。
默认推荐范围:
一笔支出的已报销金额等于所有未删除关联流水的金额之和。报销状态由金额计算,不额外存储:
- 同一账本内。
- 未删除支出。
- 发生时间早于或等于当前报销/退款时间。
- 最近 180 天内优先。
- 金额相同或接近优先。
- 未报销:已报销金额为零。
- 部分报销:已报销金额小于原支出。
- 全额报销:已报销金额等于原支出。
推荐排序建议:
1. 金额完全相同。
2. 金额差额较小。
3. 日期更近。
4. 分类更相关。
5. 备注文本有相似词。
6. 同一付款人。
### 手动关联
用户必须能:
- 搜索历史支出。
- 改选推荐结果。
- 选择多笔支出。
- 不关联任何支出,先保存为未关联报销/退款。
- 保存后再补充或修改关联。
## 金额规则
一笔报销或退款可以关联:
- 一笔支出。
- 多笔支出。
- 一笔支出的一部分金额。
关联金额需要单独记录,不能只靠收入金额和支出金额推导。
示例:
- 支出 `餐饮 100 元`
- 报销 `80 元`
- 关联金额为 `80 元`,原支出仍显示 `100 元`
多笔关联示例:
- 支出 A `交通 60 元`
- 支出 B `餐饮 40 元`
- 报销 `100 元`
- 报销记录同时关联 A 和 B。
## 统计规则
二期统计至少区分:
- 总收入:包含普通收入、报销、退款。
- 普通收入:只包含真实收入。
- 总支出:原始支出合计,不被报销/退款改写。
- 报销/退款抵扣:已关联到支出的报销和退款金额。
- 净支出:总支出减去报销/退款抵扣。
首选展示方式:
- 流水仍显示原始收入和支出,保持账目真实发生。
- 汇总页突出 `净支出`
- 报销和退款作为收入展示,但使用不同标签,避免被误认为工资等普通收入。
存在报销记录时不能删除原支出。原支出仍可修改备注、时间或增加金额,但不能改分类、币种和账本,也不能把金额降到累计报销金额以下。
## 流水展示
报销/退款收入行应显示
发生报销的原支出保留原始金额,并显示冲减后的净支出:
- 金额为正数。
- 类型标签:报销或退款。
- 关联状态:已关联、部分关联、未关联。
- 关联的原支出摘要。
```text
原金额 ¥3,200划线
净支出 ¥3,000
```
原支出行应显示:
报销到账按实际到账日期显示为独立流水,并标记为“报销”。
- 原始支出金额不变。
- 若已有报销/退款抵扣,显示抵扣金额。
- 可进入详情查看关联收入。
流水页顶部有报销时使用竖式展示支出:
状态建议:
```text
支出 ¥3,200
- 200
─────
¥3,000
```
- `未关联`:没有任何关联支出。
- `部分抵扣`:关联金额小于原支出金额。
- `已抵扣`:关联金额大于或等于原支出金额。
没有报销时只显示普通支出金额,不保留竖式空间。
## 数据模型建议
## 统计口径
### entry 扩展
- 收入:只统计普通收入,不包含报销到账。
- 原支出:支出的原始金额。
- 报销:关联到这些原支出的累计报销金额。
- 净支出:原支出减去报销。
- 结余:普通收入减去净支出。
`entry` 上增加收入子类型字段:
报销按原支出的日期、分类和账本冲减。因此即使报销在下个月到账,也会更新原支出所在期间的净支出统计。
- `income_kind``regular`、`reimbursement`、`refund`。
外币报销首版继承原支出币种。本币净支出按照原支出的本币金额比例冲减,保证全额报销后的本币净支出为零。
约束:
## 离线与同步
- `type = income` 时可填写 `income_kind`
- `type = expense``income_kind` 为空。
- 兼容旧数据时,收入默认视为 `regular`
报销流水与普通账目使用同一套 IndexedDB 和同步操作:
### entry_link
新增关联表:
- `id`:客户端生成 UUID。
- `ledger_id`
- `source_entry_id`:报销/退款收入。
- `target_entry_id`:被关联支出。
- `link_type``reimbursement` 或 `refund`
- `amount`:本次关联金额,整数分。
- `created_by`
- `created_at`
- `updated_at`
- `deleted_at`
- `version`
约束:
- `source_entry_id` 必须指向收入。
- `target_entry_id` 必须指向支出。
- 两条账目必须属于同一账本。
- 关联金额必须大于 0。
- 同一收入关联多笔支出的金额合计不应超过收入金额,除非后续明确支持超额报销。
## 同步与离线
报销/退款关联也必须离线可用:
- 新增、修改、删除关联写入本地 IndexedDB。
- 每次关联变更生成同步操作。
- 服务端按 `operation_id` 幂等处理。
- 删除账目时不硬删除关联,使用软删除。
冲突策略二期先保持简单:
- 同一条关联被多设备修改时,最后写入胜出。
- 如果原支出或收入已删除,关联保留软删除或标记失效。
- 汇总统计只计算未删除且有效的关联。
## 原型需求
需要补充这些原型:
- 快速记账收入模式下的 `普通收入 / 报销 / 退款` 切换。
- 输入金额后的候选支出匹配列表。
- 手动搜索并选择历史支出。
- 一笔收入关联多笔支出的选择态。
- 流水中报销/退款和原支出的关联展示。
- 账目详情中的关联管理。
## 待决策问题
- 报销和退款是否共用同一套分类,还是独立于普通收入分类。
- 未关联的报销/退款是否计入净支出抵扣。
- 关联金额是否允许超过原支出剩余未抵扣金额。
- AA 或代付回款是否归入报销,还是后续独立成“代付结算”。
- 统计页默认展示总支出还是净支出。
- 离线时可以创建、修改和删除报销到账。
- 恢复联网后按照操作创建时间同步。
- 服务端再次验证原支出所有者、币种、分类和累计报销金额。
- 服务端通过行锁避免多个设备同时报销导致累计金额超限。

View File

@ -66,4 +66,4 @@
- 导入导出。
- 专门的桌面大屏布局。
- 分摊和结算。
- 报销、退款和收入关联原支出,见 [二期:报销与退款](二期-报销与退款.md)。
- 报销从支出详情发起并冲减原支出;退款暂不实现,见 [报销](二期-报销与退款.md)。

View File

@ -32,7 +32,7 @@
### 收入
7 个。
6 个。
| 顺序 | 分类 | ID |
| --- | --- | --- |
@ -40,9 +40,8 @@
| 2 | 奖金 | `bonus` |
| 3 | 兼职 | `part-time` |
| 4 | 投资 | `investment` |
| 5 | 报销 | `refund` |
| 6 | 其他 | `other-income` |
| 7 | 红包 | `received-red-packet` |
| 5 | 其他 | `other-income` |
| 6 | 红包 | `received-red-packet` |
## 三级分类

View File

@ -117,6 +117,7 @@ export type LedgerEntry = {
exchangeRateEffectiveDate: string | null;
exchangeRateSource: ExchangeRateSource;
conversionStatus: ConversionStatus;
reimbursementOfEntryId: string | null;
categoryId: string;
note: string;
occurredAt: string;

View File

@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS entries (
exchange_rate_effective_date date,
exchange_rate_source varchar(8) NOT NULL CHECK (exchange_rate_source IN ('manual', 'system')),
conversion_status varchar(8) NOT NULL DEFAULT 'pending',
reimbursement_of_entry_id text REFERENCES entries(id) ON DELETE RESTRICT,
category_id varchar(100) NOT NULL,
note varchar(500) NOT NULL DEFAULT '',
occurred_at timestamptz NOT NULL,
@ -132,6 +133,7 @@ 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';
ALTER TABLE entries ADD COLUMN IF NOT EXISTS reimbursement_of_entry_id text REFERENCES entries(id) ON DELETE RESTRICT;
UPDATE entries
SET base_currency = 'CNY',
base_amount = amount,
@ -178,6 +180,12 @@ ALTER TABLE entries ADD CONSTRAINT entries_conversion_values_check
OR
(conversion_status IN ('exact', 'fallback') AND base_amount > 0 AND exchange_rate IS NOT NULL)
);
ALTER TABLE entries DROP CONSTRAINT IF EXISTS entries_reimbursement_shape_check;
ALTER TABLE entries ADD CONSTRAINT entries_reimbursement_shape_check
CHECK (
reimbursement_of_entry_id IS NULL
OR (type = 'income' AND reimbursement_of_entry_id <> id)
);
CREATE TABLE IF NOT EXISTS entry_ledgers (
entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
@ -208,6 +216,9 @@ ALTER TABLE entries DROP COLUMN IF EXISTS ledger_id;
CREATE INDEX IF NOT EXISTS entries_owner_occurred
ON entries (owner_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS entries_reimbursement_of
ON entries (reimbursement_of_entry_id)
WHERE reimbursement_of_entry_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS entry_ledgers_ledger
ON entry_ledgers (ledger_id, entry_id);

View File

@ -180,6 +180,12 @@ function validEntry(value: unknown): value is LedgerEntry {
&& (entry.conversionStatus === "pending"
? entry.baseAmount === null && entry.exchangeRate === null
: entry.baseAmount !== null && entry.exchangeRate !== null && entry.exchangeRateEffectiveDate !== null)
&& (entry.reimbursementOfEntryId === undefined
|| entry.reimbursementOfEntryId === null
|| (typeof entry.reimbursementOfEntryId === "string"
&& entry.reimbursementOfEntryId.length > 0
&& entry.reimbursementOfEntryId.length <= 100
&& entry.reimbursementOfEntryId !== entry.id))
&& typeof entry.categoryId === "string" && entry.categoryId.length > 0 && entry.categoryId.length <= 100
&& typeof entry.note === "string" && entry.note.length <= 500
&& validDate(entry.occurredAt) && validDate(entry.createdAt) && validDate(entry.updatedAt)
@ -577,8 +583,12 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
const existing = await client.query<{
ownerId: string;
canAccess: boolean;
type: "expense" | "income";
amount: number;
currency: CurrencyCode;
categoryId: string;
ledgerIds: string[];
reimbursementOfEntryId: string | null;
exchangeRateDate: string;
baseAmount: number | null;
exchangeRate: string | null;
@ -593,7 +603,14 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
AND m.user_id = $2 AND m.removed_at IS NULL
)) AS "canAccess",
e.amount, e.currency, e.exchange_rate_date::text AS "exchangeRateDate",
e.type, e.amount, e.currency, e.category_id AS "categoryId",
ARRAY(
SELECT el.ledger_id FROM entry_ledgers el
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
ORDER BY el.ledger_id
) AS "ledgerIds",
e.reimbursement_of_entry_id AS "reimbursementOfEntryId",
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",
@ -629,10 +646,103 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
[entryOwnerId],
);
if (!personalLedger.rows[0]) throw new Error("用户缺少个人账本");
const targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])];
const entry = operation.payload;
const reimbursementOfEntryId = entry.reimbursementOfEntryId === undefined
? existing.rows[0]?.reimbursementOfEntryId ?? null
: entry.reimbursementOfEntryId;
if (
existing.rows[0]
&& existing.rows[0].reimbursementOfEntryId !== reimbursementOfEntryId
) {
await client.query("ROLLBACK");
return reply.code(409).send({ error: "不能修改报销关联" });
}
let targetLedgerIds = [...new Set([...operation.ledgerIds, personalLedger.rows[0].id])];
if (reimbursementOfEntryId) {
const source = await client.query<{
id: string;
ownerId: string;
type: "expense" | "income";
amount: number;
currency: CurrencyCode;
categoryId: string;
ledgerIds: string[];
deletedAt: Date | null;
}>(
`SELECT e.id, e.owner_id AS "ownerId", e.type, e.amount, e.currency,
e.category_id AS "categoryId", e.deleted_at AS "deletedAt",
ARRAY(
SELECT el.ledger_id FROM entry_ledgers el
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
ORDER BY el.ledger_id
) AS "ledgerIds"
FROM entries e
WHERE e.id = $1
FOR UPDATE`,
[reimbursementOfEntryId],
);
const expense = source.rows[0];
if (
!expense
|| expense.deletedAt
|| expense.ownerId !== user.id
|| expense.type !== "expense"
) {
await client.query("ROLLBACK");
return reply.code(409).send({ error: "关联的原支出无效" });
}
if (
operation.action !== "delete"
&& (entry.type !== "income"
|| entry.currency !== expense.currency
|| entry.categoryId !== expense.categoryId)
) {
await client.query("ROLLBACK");
return reply.code(409).send({ error: "报销必须继承原支出的币种和分类" });
}
const reimbursed = await client.query<{ amount: number }>(
`SELECT COALESCE(sum(amount), 0)::int AS amount
FROM entries
WHERE reimbursement_of_entry_id = $1
AND id <> $2
AND deleted_at IS NULL`,
[expense.id, entry.id],
);
if (operation.action !== "delete" && reimbursed.rows[0]!.amount + entry.amount > expense.amount) {
await client.query("ROLLBACK");
return reply.code(409).send({ error: "报销金额超过剩余可报销金额" });
}
targetLedgerIds = expense.ledgerIds;
}
const existingEntry = existing.rows[0];
if (existingEntry?.type === "expense") {
const reimbursements = await client.query<{ amount: number }>(
`SELECT COALESCE(sum(amount), 0)::int AS amount
FROM entries
WHERE reimbursement_of_entry_id = $1
AND deleted_at IS NULL`,
[entry.id],
);
const reimbursed = reimbursements.rows[0]!.amount;
if (reimbursed > 0 && operation.action === "delete") {
await client.query("ROLLBACK");
return reply.code(409).send({ error: "存在报销记录,不能删除原支出" });
}
if (
reimbursed > entry.amount
|| (reimbursed > 0 && (
entry.type !== "expense"
|| entry.currency !== existingEntry.currency
|| entry.categoryId !== existingEntry.categoryId
|| [...existingEntry.ledgerIds].sort().join(",") !== [...targetLedgerIds].sort().join(",")
))
) {
await client.query("ROLLBACK");
return reply.code(409).send({ error: "存在报销时不能缩小支出金额、改变分类、币种或账本" });
}
}
const existingRateDate = existingEntry?.exchangeRateDate?.slice(0, 10);
const conversionChanged = !existingEntry
|| existingEntry.amount !== entry.amount
@ -665,11 +775,12 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
`INSERT INTO entries (
id, owner_id, type, amount, currency, base_currency, base_amount,
exchange_rate, exchange_rate_date, exchange_rate_effective_date,
exchange_rate_source, conversion_status, category_id, note, occurred_at,
exchange_rate_source, conversion_status, reimbursement_of_entry_id,
category_id, note, occurred_at,
created_by, updated_by, created_at, updated_at, deleted_at, version
) VALUES (
$1, $2, $3, $4, $5, 'CNY', $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20
$14, $15, $16, $17, $18, $19, $20, $21
)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
@ -682,6 +793,7 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
exchange_rate_effective_date = EXCLUDED.exchange_rate_effective_date,
exchange_rate_source = EXCLUDED.exchange_rate_source,
conversion_status = EXCLUDED.conversion_status,
reimbursement_of_entry_id = EXCLUDED.reimbursement_of_entry_id,
category_id = EXCLUDED.category_id,
note = EXCLUDED.note,
occurred_at = EXCLUDED.occurred_at,
@ -695,9 +807,9 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
[
entry.id, user.id, entry.type, entry.amount, entry.currency,
conversion.baseAmount, conversion.exchangeRate, entry.exchangeRateDate,
conversion.effectiveDate, conversion.source, conversion.status, entry.categoryId,
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
entry.deletedAt, entry.version,
conversion.effectiveDate, conversion.source, conversion.status, reimbursementOfEntryId,
entry.categoryId, entry.note, entry.occurredAt, user.id, user.id, entry.createdAt,
entry.updatedAt, entry.deletedAt, entry.version,
],
);
if (operation.action !== "delete" && writeResult.rowCount) {
@ -777,6 +889,7 @@ server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (reques
e.exchange_rate_effective_date::text AS "exchangeRateEffectiveDate",
e.exchange_rate_source AS "exchangeRateSource",
e.conversion_status AS "conversionStatus",
e.reimbursement_of_entry_id AS "reimbursementOfEntryId",
e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
e.created_at AS "createdAt", e.updated_at AS "updatedAt",