Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
+87 -4
View File
@@ -15,6 +15,9 @@ class TxItem {
final String categoryName, categoryIcon, categoryColor, type, source;
final double amount;
final String? note, paymentMethod, sourceText;
final String? transferDirection, counterparty;
final String? provider, providerTransactionId, recognitionOccurrenceId;
final String? evidenceFingerprint, recognitionConfidence;
final DateTime occurredAt, updatedAt;
final bool isDeleted;
@@ -31,6 +34,13 @@ class TxItem {
paymentMethod = j['paymentMethod'] as String?,
source = j['source'] as String,
sourceText = j['sourceText'] as String?,
transferDirection = j['transferDirection'] as String?,
counterparty = j['counterparty'] as String?,
provider = j['provider'] as String?,
providerTransactionId = j['providerTransactionId'] as String?,
recognitionOccurrenceId = j['recognitionOccurrenceId'] as String?,
evidenceFingerprint = j['evidenceFingerprint'] as String?,
recognitionConfidence = j['recognitionConfidence'] as String?,
occurredAt = ShanghaiTime.parseCivil(j['occurredAt'] as String),
updatedAt = DateTime.parse(
j['updatedAt'] as String? ?? j['occurredAt'] as String,
@@ -48,7 +58,18 @@ class TxItem {
source == 'accessibility' ||
source == 'notification' ||
source == 'local_ocr';
bool get isIncome => type == 'income';
bool get isTransfer => type == 'transfer';
bool get isIncome =>
type == 'income' || isTransfer && transferDirection == 'in';
bool get isExpense =>
type == 'expense' || isTransfer && transferDirection == 'out';
String get typeLabel => isTransfer
? transferDirection == 'in'
? '转入'
: '转出'
: isIncome
? '收入'
: '支出';
String get sourceLabel => switch (source) {
'ai_chat' => 'AI 聊天',
@@ -191,6 +212,9 @@ class RecognitionBatchDraft {
final double amount;
final DateTime occurredAt;
final String? note, paymentMethod, sourceText;
final String? transferDirection, counterparty;
final String? provider, providerTransactionId, recognitionOccurrenceId;
final String? evidenceFingerprint, recognitionConfidence;
const RecognitionBatchDraft({
required this.candidateId,
@@ -203,6 +227,13 @@ class RecognitionBatchDraft {
this.note,
this.paymentMethod,
this.sourceText,
this.transferDirection,
this.counterparty,
this.provider,
this.providerTransactionId,
this.recognitionOccurrenceId,
this.evidenceFingerprint,
this.recognitionConfidence,
});
}
@@ -336,6 +367,13 @@ class TxApi {
String? sourceText,
DateTime? occurredAt,
String? clientRequestId,
String? transferDirection,
String? counterparty,
String? provider,
String? providerTransactionId,
String? recognitionOccurrenceId,
String? evidenceFingerprint,
String? recognitionConfidence,
}) async {
final payload = <String, dynamic>{
'ledgerId': _ledgerId,
@@ -350,6 +388,17 @@ class TxApi {
occurredAt ?? ShanghaiTime.now,
).toIso8601String(),
if (clientRequestId != null) 'clientRequestId': clientRequestId,
if (transferDirection != null) 'transferDirection': transferDirection,
if (counterparty != null) 'counterparty': counterparty,
if (provider != null) 'provider': provider,
if (providerTransactionId != null)
'providerTransactionId': providerTransactionId,
if (recognitionOccurrenceId != null)
'recognitionOccurrenceId': recognitionOccurrenceId,
if (evidenceFingerprint != null)
'evidenceFingerprint': evidenceFingerprint,
if (recognitionConfidence != null)
'recognitionConfidence': recognitionConfidence,
};
final session = SessionStore.instance;
if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) {
@@ -402,6 +451,13 @@ class TxApi {
draft.occurredAt,
).toIso8601String(),
'clientRequestId': draft.clientRequestId,
'transferDirection': draft.transferDirection,
'counterparty': draft.counterparty,
'provider': draft.provider,
'providerTransactionId': draft.providerTransactionId,
'recognitionOccurrenceId': draft.recognitionOccurrenceId,
'evidenceFingerprint': draft.evidenceFingerprint,
'recognitionConfidence': draft.recognitionConfidence,
},
)
.toList(growable: false);
@@ -442,6 +498,14 @@ class TxApi {
'occurredAt': localPayloads[index]['occurredAt'],
'source': drafts[index].source,
'sourceText': drafts[index].sourceText,
'transferDirection': drafts[index].transferDirection,
'counterparty': drafts[index].counterparty,
'provider': drafts[index].provider,
'providerTransactionId': drafts[index].providerTransactionId,
'recognitionOccurrenceId':
drafts[index].recognitionOccurrenceId,
'evidenceFingerprint': drafts[index].evidenceFingerprint,
'recognitionConfidence': drafts[index].recognitionConfidence,
},
],
},
@@ -499,6 +563,8 @@ class TxApi {
required DateTime occurredAt,
String? note,
String? paymentMethod,
String? transferDirection,
String? counterparty,
}) async {
final baseUpdatedAt = LocalDatabase.instance.transaction(
id,
@@ -511,6 +577,8 @@ class TxApi {
'amount': amount,
'note': note,
'paymentMethod': paymentMethod,
'transferDirection': transferDirection,
'counterparty': counterparty,
'occurredAt': ShanghaiTime.civilToUtc(occurredAt).toIso8601String(),
if (baseUpdatedAt != null) 'baseUpdatedAt': baseUpdatedAt,
};
@@ -1447,7 +1515,7 @@ class ParsedDraft {
final int categoryId;
final String categoryName, categoryIcon, note, type;
final double amount;
final String? paymentMethod;
final String? paymentMethod, transferDirection, counterparty;
ParsedDraft.fromJson(Map<String, dynamic> j)
: matched = j['matched'] as bool,
@@ -1456,12 +1524,25 @@ class ParsedDraft {
categoryIcon = j['categoryIcon'] as String,
amount = (j['amount'] as num).toDouble(),
paymentMethod = j['paymentMethod'] as String?,
transferDirection = j['transferDirection'] as String?,
counterparty = j['counterparty'] as String?,
note = j['note'] as String,
type = switch (j['type']?.toString().toLowerCase()) {
'income' => 'income',
'expense' => 'expense',
'transfer' => 'transfer',
_ => 'unknown',
};
bool get isIncome =>
type == 'income' || type == 'transfer' && transferDirection == 'in';
String get typeLabel => type == 'transfer'
? transferDirection == 'in'
? '转入'
: '转出'
: isIncome
? '收入'
: '支出';
}
class ParseApi {
@@ -1481,8 +1562,8 @@ class ParseApi {
String source,
String sourceText,
) async {
if (d.type != 'income' && d.type != 'expense') {
throw StateError('请先确认收入或支出类型');
if (d.type != 'income' && d.type != 'expense' && d.type != 'transfer') {
throw StateError('请先确认账单类型');
}
return TxApi.create(
categoryId: d.categoryId,
@@ -1492,6 +1573,8 @@ class ParseApi {
paymentMethod: d.paymentMethod,
source: source,
sourceText: sourceText,
transferDirection: d.transferDirection,
counterparty: d.counterparty,
);
}
}