feat(ledger): add bookkeeping workbench and transaction views

- Add transactions_dirty table and zero-tolerance cleansing action

- Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier

- Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters

- Update AGENTS.md guidelines for Base UI Select and Combobox bindings
This commit is contained in:
2026-09-06 23:23:29 +08:00
parent 1ccee1414c
commit c33857ffac
42 changed files with 3329 additions and 598 deletions
+11 -9
View File
@@ -1,15 +1,17 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import * as schema from "./schema"
const connectionString = process.env.DATABASE_URL || "postgresql://postgres:Aa110011@localhost:5432/fluxent";
const connectionString =
process.env.DATABASE_URL ||
"postgresql://postgres:Aa110011@localhost:5432/fluxent"
// 在 Next.js 开发环境下避免热重载创建重复连接池
const globalForDb = globalThis as unknown as {
conn: postgres.Sql | undefined;
};
conn: postgres.Sql | undefined
}
const client = globalForDb.conn ?? postgres(connectionString, { max: 10 });
if (process.env.NODE_ENV !== "production") globalForDb.conn = client;
const client = globalForDb.conn ?? postgres(connectionString, { max: 10 })
if (process.env.NODE_ENV !== "production") globalForDb.conn = client
export const db = drizzle(client, { schema });
export const db = drizzle(client, { schema })
+4 -1
View File
@@ -41,7 +41,10 @@ async function main() {
`
console.log(`Normalized ${updated} channel card brands.`)
console.log("Stored card brands:", brands.map((row) => row.card_brand))
console.log(
"Stored card brands:",
brands.map((row) => row.card_brand)
)
}
main()
+15 -15
View File
@@ -1,22 +1,22 @@
import postgres from "postgres";
import * as dotenv from "dotenv";
import postgres from "postgres"
import * as dotenv from "dotenv"
dotenv.config({ path: ".env.local" });
dotenv.config({ path: ".env.local" })
const sql = postgres(process.env.DATABASE_URL!);
const sql = postgres(process.env.DATABASE_URL!)
async function resetAndInit() {
console.log("Dropping old tables...");
await sql`DROP TABLE IF EXISTS transactions CASCADE`;
await sql`DROP TABLE IF EXISTS channels CASCADE`;
await sql`DROP TABLE IF EXISTS accounts CASCADE`;
await sql`DROP TABLE IF EXISTS user_accounts CASCADE`;
await sql`DROP TABLE IF EXISTS users CASCADE`;
console.log("Old tables dropped.");
await sql.end();
console.log("Dropping old tables...")
await sql`DROP TABLE IF EXISTS transactions CASCADE`
await sql`DROP TABLE IF EXISTS channels CASCADE`
await sql`DROP TABLE IF EXISTS accounts CASCADE`
await sql`DROP TABLE IF EXISTS user_accounts CASCADE`
await sql`DROP TABLE IF EXISTS users CASCADE`
console.log("Old tables dropped.")
await sql.end()
}
resetAndInit().catch((err) => {
console.error(err);
process.exit(1);
});
console.error(err)
process.exit(1)
})
+111 -39
View File
@@ -8,8 +8,8 @@ import {
jsonb,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
} from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
// ---------------------------------------------------------------------------
// 基础时间戳辅助
@@ -22,7 +22,7 @@ export const timestamps = {
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
};
}
// ---------------------------------------------------------------------------
// 1. 用户与认证表
@@ -42,13 +42,11 @@ export const users = pgTable(
isActive: boolean("is_active").default(true).notNull(),
...timestamps,
},
(t) => [
uniqueIndex("users_email_unique").on(t.email),
]
);
(t) => [uniqueIndex("users_email_unique").on(t.email)]
)
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
/**
* 用户认证授权表 (支持 OIDC / OAuth 账号关联)
@@ -61,7 +59,9 @@ export const userAccounts = pgTable(
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
provider: varchar("provider", { length: 64 }).notNull(), // 如 'oidc'
providerAccountId: varchar("provider_account_id", { length: 255 }).notNull(),
providerAccountId: varchar("provider_account_id", {
length: 255,
}).notNull(),
refreshToken: text("refresh_token"),
accessToken: text("access_token"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
@@ -77,14 +77,14 @@ export const userAccounts = pgTable(
),
index("user_accounts_user_id_idx").on(t.userId),
]
);
)
// ---------------------------------------------------------------------------
// 2. 资金账户表 (Accounts)
// ---------------------------------------------------------------------------
export type AccountType = "BANK" | "E_WALLET" | "CASH";
export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY";
export type AccountType = "BANK" | "E_WALLET" | "CASH"
export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY"
export const accounts = pgTable(
"accounts",
@@ -94,7 +94,9 @@ export const accounts = pgTable(
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
name: varchar("name", { length: 150 }).notNull(),
accountType: varchar("account_type", { length: 32 }).$type<AccountType>().notNull(),
accountType: varchar("account_type", { length: 32 })
.$type<AccountType>()
.notNull(),
balanceType: varchar("balance_type", { length: 32 })
.$type<BalanceType>()
.default("ASSET")
@@ -119,20 +121,18 @@ export const accounts = pgTable(
...timestamps,
},
(t) => [
index("accounts_user_id_idx").on(t.userId),
]
);
(t) => [index("accounts_user_id_idx").on(t.userId)]
)
export type Account = typeof accounts.$inferSelect;
export type NewAccount = typeof accounts.$inferInsert;
export type Account = typeof accounts.$inferSelect
export type NewAccount = typeof accounts.$inferInsert
// ---------------------------------------------------------------------------
// 3. 渠道表 (Channels)
// ---------------------------------------------------------------------------
export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER";
export type PaymentInstrumentType = "CREDIT" | "DEBIT";
export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER"
export type PaymentInstrumentType = "CREDIT" | "DEBIT"
export const channels = pgTable(
"channels",
@@ -141,7 +141,9 @@ export const channels = pgTable(
userId: uuid("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
channelType: varchar("channel_type", { length: 32 }).$type<ChannelType>().notNull(),
channelType: varchar("channel_type", { length: 32 })
.$type<ChannelType>()
.notNull(),
refAccounts: jsonb("ref_accounts").$type<string[]>().notNull(), // 关联的账户 UUID 列表
isActive: boolean("is_active").default(true).notNull(),
desc: text("desc"),
@@ -150,7 +152,9 @@ export const channels = pgTable(
// 支付卡渠道字段
region: varchar("region", { length: 10 }), // 发卡地如 HK, CN
issuerName: varchar("issuer_name", { length: 100 }),
cardType: varchar("card_type", { length: 32 }).$type<PaymentInstrumentType>(),
cardType: varchar("card_type", {
length: 32,
}).$type<PaymentInstrumentType>(),
cardNumberFull: varchar("card_number_full", { length: 100 }),
cardNumberSuffix: varchar("card_number_suffix", { length: 10 }),
cardBrand: varchar("card_brand", { length: 32 }),
@@ -159,29 +163,29 @@ export const channels = pgTable(
platform: varchar("platform", { length: 64 }),
platformAccountId: varchar("platform_account_id", { length: 100 }),
subChannel: varchar("sub_channel", { length: 64 }),
subChannelType: varchar("sub_channel_type", { length: 32 }).$type<PaymentInstrumentType>(),
subChannelType: varchar("sub_channel_type", {
length: 32,
}).$type<PaymentInstrumentType>(),
...timestamps,
},
(t) => [
index("channels_user_id_idx").on(t.userId),
]
);
(t) => [index("channels_user_id_idx").on(t.userId)]
)
export type Channel = typeof channels.$inferSelect;
export type NewChannel = typeof channels.$inferInsert;
export type Channel = typeof channels.$inferSelect
export type NewChannel = typeof channels.$inferInsert
// ---------------------------------------------------------------------------
// 4. 交易记账表 (Transactions)
// ---------------------------------------------------------------------------
export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED";
export type DcFlag = "DEBIT" | "CREDIT";
export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED"
export type DcFlag = "DEBIT" | "CREDIT"
export interface FxRateItem {
fromCcy: string;
toCcy: string;
rate: string;
fromCcy: string
toCcy: string
rate: string
}
export const transactions = pgTable(
@@ -238,7 +242,75 @@ export const transactions = pgTable(
index("transactions_user_id_idx").on(t.userId),
index("transactions_txn_date_idx").on(t.txnDate),
]
);
)
export type Transaction = typeof transactions.$inferSelect;
export type NewTransaction = typeof transactions.$inferInsert;
export type Transaction = typeof transactions.$inferSelect
export type NewTransaction = typeof transactions.$inferInsert
// ---------------------------------------------------------------------------
// 5. 待清洗暂存交易表 (Transactions Dirty)
// ---------------------------------------------------------------------------
export const transactionsDirty = pgTable(
"transactions_dirty",
{
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
version: varchar("version", { length: 16 }).default("0").notNull(),
refTransactions: jsonb("ref_transactions").$type<string[]>(),
txnDate: timestamp("txn_date", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
clearingDate: timestamp("clearing_date", { withTimezone: true }),
postingDate: timestamp("posting_date", { withTimezone: true }),
// 金额与币种(草稿阶段)
txnAmt: varchar("txn_amt", { length: 32 }).notNull(),
txnCcy: varchar("txn_ccy", { length: 10 }).notNull(),
postingAmt: varchar("posting_amt", { length: 32 }),
postingCcy: varchar("posting_ccy", { length: 10 }),
commAmt: varchar("comm_amt", { length: 32 }),
commCcy: varchar("comm_ccy", { length: 10 }),
surchargeAmt: varchar("surcharge_amt", { length: 32 }),
surchargeCcy: varchar("surcharge_ccy", { length: 10 }),
discAmt: varchar("disc_amt", { length: 32 }),
discCcy: varchar("disc_ccy", { length: 10 }),
fxRates: jsonb("fx_rates").$type<FxRateItem[]>(),
dcFlag: varchar("dc_flag", { length: 16 })
.$type<DcFlag>()
.default("DEBIT")
.notNull(),
refChannels: jsonb("ref_channels").$type<string[]>().default([]).notNull(),
cp: varchar("cp", { length: 255 }),
acqInst: varchar("acq_inst", { length: 150 }),
clearingNetwork: varchar("clearing_network", { length: 100 }),
txnSts: varchar("txn_sts", { length: 32 })
.$type<TransactionStatus>()
.default("COMPLETED")
.notNull(),
description: text("description"),
memo: text("memo"),
ext: text("ext"),
rawDescription: text("raw_description"),
rawData: jsonb("raw_data"),
// 场景类型
txnScene: varchar("txn_scene", { length: 64 }).default("PAYMENT").notNull(),
merchantName: varchar("merchant_name", { length: 255 }),
orderId: varchar("order_id", { length: 150 }),
geo: varchar("geo", { length: 64 }),
...timestamps,
},
(t) => [
index("transactions_dirty_user_id_idx").on(t.userId),
index("transactions_dirty_txn_date_idx").on(t.txnDate),
]
)
export type TransactionDirty = typeof transactionsDirty.$inferSelect
export type NewTransactionDirty = typeof transactionsDirty.$inferInsert