Compare commits
3
Commits
v2.0
..
2e40786bcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e40786bcd | ||
|
|
38bcf01ae3 | ||
|
|
00ab05eefc |
@@ -113,6 +113,23 @@ class BankTransaction {
|
|||||||
/// Exact match for copying notes (same description + amount + currency).
|
/// Exact match for copying notes (same description + amount + currency).
|
||||||
String get exactKey => '$descriptionKey|$amount|$currency';
|
String get exactKey => '$descriptionKey|$amount|$currency';
|
||||||
|
|
||||||
|
static String stripManualStamp(String description) =>
|
||||||
|
description.replaceFirst(RegExp(r' ·#\d+$'), '');
|
||||||
|
|
||||||
|
/// True when [other] is the same row for delete/tombstone matching.
|
||||||
|
///
|
||||||
|
/// Uses id first, then date + cents + currency + description (stamp-insensitive),
|
||||||
|
/// so PocketBase float/amount rehydration cannot bypass a local delete.
|
||||||
|
bool matchesForDelete(BankTransaction other) {
|
||||||
|
if (id == other.id) return true;
|
||||||
|
if (date != other.date) return false;
|
||||||
|
if (currency.toUpperCase() != other.currency.toUpperCase()) return false;
|
||||||
|
if ((amount * 100).round() != (other.amount * 100).round()) return false;
|
||||||
|
if (descriptionKey == other.descriptionKey) return true;
|
||||||
|
return stripManualStamp(description).trim().toLowerCase() ==
|
||||||
|
stripManualStamp(other.description).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
BankTransaction copyWith({
|
BankTransaction copyWith({
|
||||||
String? date,
|
String? date,
|
||||||
String? description,
|
String? description,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/// [ChangeNotifier] for bank link status, balance, and transactions.
|
/// [ChangeNotifier] for bank link status, balance, and transactions.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/bank.dart';
|
import '../models/bank.dart';
|
||||||
@@ -36,6 +38,18 @@ class BankController extends ChangeNotifier {
|
|||||||
_remoteTransactions = [
|
_remoteTransactions = [
|
||||||
for (final tx in _remoteTransactions) _upgradeLegacyManual(tx),
|
for (final tx in _remoteTransactions) _upgradeLegacyManual(tx),
|
||||||
];
|
];
|
||||||
|
_pendingRemoteDeletes = _storage?.loadPendingTxDeletes() ?? const [];
|
||||||
|
// Drop anything still queued for remote delete from the local caches.
|
||||||
|
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||||
|
_remoteTransactions = [
|
||||||
|
for (final tx in _remoteTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
_importedTransactions = [
|
||||||
|
for (final tx in _importedTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
}
|
||||||
_invalidateMerged();
|
_invalidateMerged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,29 +69,90 @@ class BankController extends ChangeNotifier {
|
|||||||
/// cannot leave the device behind the cloud (or wipe cloud-only history).
|
/// cannot leave the device behind the cloud (or wipe cloud-only history).
|
||||||
VoidCallback? onAfterSuccessfulLoad;
|
VoidCallback? onAfterSuccessfulLoad;
|
||||||
|
|
||||||
|
/// Fired when local admin txs are removed/replaced and need a remote delete.
|
||||||
|
VoidCallback? onPendingRemoteDeletesChanged;
|
||||||
|
|
||||||
ConnectStatus _status = ConnectStatus.disconnected;
|
ConnectStatus _status = ConnectStatus.disconnected;
|
||||||
BankBalance? _balance;
|
BankBalance? _balance;
|
||||||
List<BankTransaction> _remoteTransactions = const [];
|
List<BankTransaction> _remoteTransactions = const [];
|
||||||
List<BankTransaction> _importedTransactions = const [];
|
List<BankTransaction> _importedTransactions = const [];
|
||||||
List<String> _pendingRemoteDeletes = const [];
|
List<BankTransaction> _pendingRemoteDeletes = const [];
|
||||||
|
|
||||||
/// Local tx_ids removed/replaced that should be deleted on the next PB push.
|
/// Tombstoned txs waiting to be removed from PocketBase (also persisted).
|
||||||
List<String> takePendingRemoteDeletes() {
|
List<BankTransaction> get pendingRemoteDeletes =>
|
||||||
final out = List<String>.of(_pendingRemoteDeletes);
|
List<BankTransaction>.unmodifiable(_pendingRemoteDeletes);
|
||||||
_pendingRemoteDeletes = const [];
|
|
||||||
return out;
|
bool get hasPendingRemoteDeletes => _pendingRemoteDeletes.isNotEmpty;
|
||||||
|
|
||||||
|
bool _isTombstoned(BankTransaction tx) {
|
||||||
|
for (final tomb in _pendingRemoteDeletes) {
|
||||||
|
if (tx.matchesForDelete(tomb)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void requeueRemoteDeletes(Iterable<String> txIds) {
|
void requeueRemoteDeletes(Iterable<BankTransaction> txs) {
|
||||||
for (final id in txIds) {
|
for (final tx in txs) {
|
||||||
_queueRemoteDelete(id);
|
_queueRemoteDelete(tx, notify: false);
|
||||||
|
}
|
||||||
|
unawaited(_persistPendingDeletes());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _queueRemoteDelete(BankTransaction tx, {bool notify = true}) {
|
||||||
|
if (tx.id.isEmpty) return;
|
||||||
|
if (_isTombstoned(tx)) return;
|
||||||
|
_pendingRemoteDeletes = [
|
||||||
|
..._pendingRemoteDeletes,
|
||||||
|
tx.copyWith(isManual: true),
|
||||||
|
];
|
||||||
|
unawaited(_persistPendingDeletes());
|
||||||
|
if (notify) onPendingRemoteDeletesChanged?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge delete tombstones from another device and strip matching local rows.
|
||||||
|
Future<void> applyRemoteDeletes(
|
||||||
|
List<BankTransaction> remote, {
|
||||||
|
bool notify = true,
|
||||||
|
}) async {
|
||||||
|
var tombsChanged = false;
|
||||||
|
for (final tomb in remote) {
|
||||||
|
if (_isTombstoned(tomb)) continue;
|
||||||
|
_pendingRemoteDeletes = [
|
||||||
|
..._pendingRemoteDeletes,
|
||||||
|
tomb.copyWith(isManual: true),
|
||||||
|
];
|
||||||
|
tombsChanged = true;
|
||||||
|
}
|
||||||
|
if (_pendingRemoteDeletes.isEmpty) return;
|
||||||
|
|
||||||
|
final beforeRemote = _remoteTransactions.length;
|
||||||
|
final beforeImported = _importedTransactions.length;
|
||||||
|
_remoteTransactions = [
|
||||||
|
for (final tx in _remoteTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
_importedTransactions = [
|
||||||
|
for (final tx in _importedTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
final stripped = beforeRemote != _remoteTransactions.length ||
|
||||||
|
beforeImported != _importedTransactions.length;
|
||||||
|
|
||||||
|
if (tombsChanged || stripped) {
|
||||||
|
_invalidateMerged();
|
||||||
|
if (tombsChanged) await _persistPendingDeletes();
|
||||||
|
if (stripped) {
|
||||||
|
await _persistCache();
|
||||||
|
await _persistImported();
|
||||||
|
}
|
||||||
|
if (notify) notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _queueRemoteDelete(String txId) {
|
Future<void> _persistPendingDeletes() async {
|
||||||
if (txId.isEmpty) return;
|
final storage = _storage;
|
||||||
if (_pendingRemoteDeletes.contains(txId)) return;
|
if (storage == null) return;
|
||||||
_pendingRemoteDeletes = [..._pendingRemoteDeletes, txId];
|
await storage.savePendingTxDeletes(_pendingRemoteDeletes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keep [isManual] when a synced/API row replaces a matching imported test tx.
|
/// Keep [isManual] when a synced/API row replaces a matching imported test tx.
|
||||||
@@ -300,7 +375,7 @@ class BankController extends ChangeNotifier {
|
|||||||
if (tx.id != previous.id) tx,
|
if (tx.id != previous.id) tx,
|
||||||
];
|
];
|
||||||
if (previous.id != updated.id) {
|
if (previous.id != updated.id) {
|
||||||
_queueRemoteDelete(previous.id);
|
_queueRemoteDelete(previous);
|
||||||
}
|
}
|
||||||
_importedTransactions = mergeTransactions(_importedTransactions, [updated]);
|
_importedTransactions = mergeTransactions(_importedTransactions, [updated]);
|
||||||
_invalidateMerged();
|
_invalidateMerged();
|
||||||
@@ -317,13 +392,13 @@ class BankController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_importedTransactions = [
|
_importedTransactions = [
|
||||||
for (final row in _importedTransactions)
|
for (final row in _importedTransactions)
|
||||||
if (row.id != tx.id) row,
|
if (!row.matchesForDelete(tx)) row,
|
||||||
];
|
];
|
||||||
_remoteTransactions = [
|
_remoteTransactions = [
|
||||||
for (final row in _remoteTransactions)
|
for (final row in _remoteTransactions)
|
||||||
if (row.id != tx.id) row,
|
if (!row.matchesForDelete(tx)) row,
|
||||||
];
|
];
|
||||||
_queueRemoteDelete(tx.id);
|
_queueRemoteDelete(tx);
|
||||||
_invalidateMerged();
|
_invalidateMerged();
|
||||||
await _persistImported();
|
await _persistImported();
|
||||||
await _persistCache();
|
await _persistCache();
|
||||||
@@ -337,17 +412,36 @@ class BankController extends ChangeNotifier {
|
|||||||
List<BankTransaction> fromCloud, {
|
List<BankTransaction> fromCloud, {
|
||||||
bool notify = true,
|
bool notify = true,
|
||||||
}) async {
|
}) async {
|
||||||
if (fromCloud.isEmpty) return;
|
if (fromCloud.isEmpty && _pendingRemoteDeletes.isEmpty) return;
|
||||||
|
|
||||||
final manuals = _collectManualIds(fromCloud.map(_upgradeLegacyManual));
|
// Never resurrect rows the user already deleted locally.
|
||||||
_remoteTransactions = mergeTransactions(
|
final incoming = [
|
||||||
_remoteTransactions,
|
for (final tx in fromCloud)
|
||||||
[for (final tx in fromCloud) _upgradeLegacyManual(tx)],
|
if (!_isTombstoned(tx)) _upgradeLegacyManual(tx),
|
||||||
);
|
];
|
||||||
|
if (incoming.isEmpty && _pendingRemoteDeletes.isEmpty) return;
|
||||||
|
|
||||||
|
final manuals = _collectManualIds(incoming);
|
||||||
|
if (incoming.isNotEmpty) {
|
||||||
|
_remoteTransactions = mergeTransactions(_remoteTransactions, incoming);
|
||||||
|
}
|
||||||
|
// Strip any local copies that match a pending delete (defensive).
|
||||||
|
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||||
|
_remoteTransactions = [
|
||||||
|
for (final tx in _remoteTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
}
|
||||||
_importedTransactions = filterNewTransactions(
|
_importedTransactions = filterNewTransactions(
|
||||||
_remoteTransactions,
|
_remoteTransactions,
|
||||||
_importedTransactions,
|
_importedTransactions,
|
||||||
);
|
);
|
||||||
|
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||||
|
_importedTransactions = [
|
||||||
|
for (final tx in _importedTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
}
|
||||||
_reapplyManualFlags(manuals);
|
_reapplyManualFlags(manuals);
|
||||||
_invalidateMerged();
|
_invalidateMerged();
|
||||||
|
|
||||||
@@ -444,6 +538,16 @@ class BankController extends ChangeNotifier {
|
|||||||
_remoteTransactions,
|
_remoteTransactions,
|
||||||
_importedTransactions,
|
_importedTransactions,
|
||||||
);
|
);
|
||||||
|
if (_pendingRemoteDeletes.isNotEmpty) {
|
||||||
|
_remoteTransactions = [
|
||||||
|
for (final tx in _remoteTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
_importedTransactions = [
|
||||||
|
for (final tx in _importedTransactions)
|
||||||
|
if (!_isTombstoned(tx)) tx,
|
||||||
|
];
|
||||||
|
}
|
||||||
_reapplyManualFlags(manuals);
|
_reapplyManualFlags(manuals);
|
||||||
_invalidateMerged();
|
_invalidateMerged();
|
||||||
await _persistImported();
|
await _persistImported();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../models/app_cloud_prefs.dart';
|
import '../models/app_cloud_prefs.dart';
|
||||||
|
import '../models/bank.dart';
|
||||||
import '../models/home_filter_widget.dart';
|
import '../models/home_filter_widget.dart';
|
||||||
import '../models/overview_favourite.dart';
|
import '../models/overview_favourite.dart';
|
||||||
import '../services/pb_sync_service.dart';
|
import '../services/pb_sync_service.dart';
|
||||||
@@ -46,6 +47,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
_prefs.onAppPrefsPersisted = _scheduleAppPrefsPush;
|
_prefs.onAppPrefsPersisted = _scheduleAppPrefsPush;
|
||||||
_auth.addListener(_onAuthChanged);
|
_auth.addListener(_onAuthChanged);
|
||||||
_bank.onAfterSuccessfulLoad = _onBankDataLoaded;
|
_bank.onAfterSuccessfulLoad = _onBankDataLoaded;
|
||||||
|
_bank.onPendingRemoteDeletesChanged = _scheduleBankTxPush;
|
||||||
if (_auth.isLoggedIn) {
|
if (_auth.isLoggedIn) {
|
||||||
unawaited(syncNow());
|
unawaited(syncNow());
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
bool _pushFavouritesPending = false;
|
bool _pushFavouritesPending = false;
|
||||||
bool _pushHomeWidgetsPending = false;
|
bool _pushHomeWidgetsPending = false;
|
||||||
bool _pushAppPrefsPending = false;
|
bool _pushAppPrefsPending = false;
|
||||||
|
bool _pushBankPending = false;
|
||||||
Timer? _pushDebounce;
|
Timer? _pushDebounce;
|
||||||
Timer? _progressHold;
|
Timer? _progressHold;
|
||||||
Timer? _uiNotifyThrottle;
|
Timer? _uiNotifyThrottle;
|
||||||
@@ -198,6 +201,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
_pushFavouritesPending = false;
|
_pushFavouritesPending = false;
|
||||||
_pushHomeWidgetsPending = false;
|
_pushHomeWidgetsPending = false;
|
||||||
_pushAppPrefsPending = false;
|
_pushAppPrefsPending = false;
|
||||||
|
_pushBankPending = false;
|
||||||
_lastError = null;
|
_lastError = null;
|
||||||
_statusDetail = '';
|
_statusDetail = '';
|
||||||
_showProgressRing = false;
|
_showProgressRing = false;
|
||||||
@@ -265,6 +269,47 @@ class SyncController extends ChangeNotifier {
|
|||||||
_armPushDebounce();
|
_armPushDebounce();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleBankTxPush() {
|
||||||
|
_pushBankPending = true;
|
||||||
|
_armPushDebounce();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull shared delete tombstones, then remove matching local bank rows.
|
||||||
|
Future<void> _pullAndApplyTxDeletes(PbSyncService service) async {
|
||||||
|
try {
|
||||||
|
final remote = await service.pullTxDeletes(onStatus: _setStatus);
|
||||||
|
await _bank.applyRemoteDeletes(remote, notify: false);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('SyncController: tx_deletes pull skipped ($error)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes tombstoned bank txs from the transactions collection.
|
||||||
|
///
|
||||||
|
/// Tombstones themselves stay local + synced via `tx_deletes` so other
|
||||||
|
/// devices learn about the delete and do not push the row back.
|
||||||
|
Future<void> _flushPendingTxDeletes(PbSyncService service) async {
|
||||||
|
final pending = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
|
||||||
|
if (pending.isEmpty) return;
|
||||||
|
_setStatus('Removing deleted bank txs…');
|
||||||
|
try {
|
||||||
|
await service.deleteTransactionsMatching(pending);
|
||||||
|
// Keep tombstones — they must remain until every device has seen them.
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('SyncController: tx deletes failed ($error)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushTxDeletes(PbSyncService service) async {
|
||||||
|
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
|
||||||
|
if (tombs.isEmpty) return;
|
||||||
|
try {
|
||||||
|
await service.pushTxDeletes(tombs, onStatus: _setStatus);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('SyncController: tx_deletes push skipped ($error)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _armPushDebounce() {
|
void _armPushDebounce() {
|
||||||
if (!_auth.isLoggedIn) return;
|
if (!_auth.isLoggedIn) return;
|
||||||
if (_syncing) {
|
if (_syncing) {
|
||||||
@@ -297,6 +342,9 @@ class SyncController extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final service = PbSyncService(_auth.client);
|
final service = PbSyncService(_auth.client);
|
||||||
|
// Learn deletes from other devices before merging bank txs / pushing.
|
||||||
|
await _pullAndApplyTxDeletes(service);
|
||||||
|
await _flushPendingTxDeletes(service);
|
||||||
final remoteMeta = await service.pull(
|
final remoteMeta = await service.pull(
|
||||||
onProgress: (p) {
|
onProgress: (p) {
|
||||||
_setProgress(p * 0.35);
|
_setProgress(p * 0.35);
|
||||||
@@ -408,17 +456,14 @@ class SyncController extends ChangeNotifier {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugPrint('SyncController: app_prefs push skipped ($error)');
|
debugPrint('SyncController: app_prefs push skipped ($error)');
|
||||||
}
|
}
|
||||||
final pendingDeletes = _bank.takePendingRemoteDeletes();
|
// Retry deletes in case any were queued during the pull/merge window.
|
||||||
if (pendingDeletes.isNotEmpty) {
|
await _flushPendingTxDeletes(service);
|
||||||
try {
|
await _pushTxDeletes(service);
|
||||||
await service.deleteTransactionsByTxIds(pendingDeletes);
|
_pushBankPending = false;
|
||||||
} catch (error) {
|
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
|
||||||
debugPrint('SyncController: tx deletes skipped ($error)');
|
|
||||||
_bank.requeueRemoteDeletes(pendingDeletes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final bankSummary = await service.pushTransactions(
|
final bankSummary = await service.pushTransactions(
|
||||||
_bank.transactions,
|
_bank.transactions,
|
||||||
|
deleteTombs: tombs,
|
||||||
progressStart: 0.82,
|
progressStart: 0.82,
|
||||||
progressEnd: 1.0,
|
progressEnd: 1.0,
|
||||||
onProgress: _setProgress,
|
onProgress: _setProgress,
|
||||||
@@ -463,11 +508,17 @@ class SyncController extends ChangeNotifier {
|
|||||||
final pushFavourites = _pushFavouritesPending;
|
final pushFavourites = _pushFavouritesPending;
|
||||||
final pushHomeWidgets = _pushHomeWidgetsPending;
|
final pushHomeWidgets = _pushHomeWidgetsPending;
|
||||||
final pushAppPrefs = _pushAppPrefsPending;
|
final pushAppPrefs = _pushAppPrefsPending;
|
||||||
|
final pushBank = _pushBankPending || _bank.pendingRemoteDeletes.isNotEmpty;
|
||||||
_pushMetaPending = false;
|
_pushMetaPending = false;
|
||||||
_pushFavouritesPending = false;
|
_pushFavouritesPending = false;
|
||||||
_pushHomeWidgetsPending = false;
|
_pushHomeWidgetsPending = false;
|
||||||
_pushAppPrefsPending = false;
|
_pushAppPrefsPending = false;
|
||||||
if (!pushMeta && !pushFavourites && !pushHomeWidgets && !pushAppPrefs) {
|
_pushBankPending = false;
|
||||||
|
if (!pushMeta &&
|
||||||
|
!pushFavourites &&
|
||||||
|
!pushHomeWidgets &&
|
||||||
|
!pushAppPrefs &&
|
||||||
|
!pushBank) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,13 +531,15 @@ class SyncController extends ChangeNotifier {
|
|||||||
pushFavourites,
|
pushFavourites,
|
||||||
pushHomeWidgets,
|
pushHomeWidgets,
|
||||||
pushAppPrefs,
|
pushAppPrefs,
|
||||||
|
pushBank,
|
||||||
)) {
|
)) {
|
||||||
(false, false, false, true) => 'App prefs',
|
(false, false, false, false, true) => 'Bank txs',
|
||||||
(false, false, true, false) => 'Home widgets',
|
(false, false, false, true, false) => 'App prefs',
|
||||||
(false, false, true, true) => 'Home layout',
|
(false, false, true, false, false) => 'Home widgets',
|
||||||
(false, true, false, false) => 'Favourites',
|
(false, false, true, true, _) => 'Home layout',
|
||||||
(false, true, true, _) => 'Home layout',
|
(false, true, false, false, false) => 'Favourites',
|
||||||
(true, false, false, false) => 'Annotations',
|
(false, true, true, _, _) => 'Home layout',
|
||||||
|
(true, false, false, false, false) => 'Annotations',
|
||||||
_ => 'Changes',
|
_ => 'Changes',
|
||||||
};
|
};
|
||||||
_statusDetail = 'Pushing $label…';
|
_statusDetail = 'Pushing $label…';
|
||||||
@@ -500,7 +553,8 @@ class SyncController extends ChangeNotifier {
|
|||||||
final total = (pushMeta ? 1 : 0) +
|
final total = (pushMeta ? 1 : 0) +
|
||||||
(pushFavourites ? 1 : 0) +
|
(pushFavourites ? 1 : 0) +
|
||||||
(pushHomeWidgets ? 1 : 0) +
|
(pushHomeWidgets ? 1 : 0) +
|
||||||
(pushAppPrefs ? 1 : 0);
|
(pushAppPrefs ? 1 : 0) +
|
||||||
|
(pushBank ? 1 : 0);
|
||||||
void bump() {
|
void bump() {
|
||||||
done++;
|
done++;
|
||||||
_setProgress(done / total);
|
_setProgress(done / total);
|
||||||
@@ -564,6 +618,29 @@ class SyncController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
bump();
|
bump();
|
||||||
}
|
}
|
||||||
|
if (pushBank) {
|
||||||
|
_setStatus('Updating bank txs…');
|
||||||
|
// Other devices may have deleted test txs — learn that before push.
|
||||||
|
await _pullAndApplyTxDeletes(service);
|
||||||
|
await _flushPendingTxDeletes(service);
|
||||||
|
await _pushTxDeletes(service);
|
||||||
|
try {
|
||||||
|
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
|
||||||
|
final summary = await service.pushTransactions(
|
||||||
|
_bank.transactions,
|
||||||
|
deleteTombs: tombs,
|
||||||
|
progressStart: done / total,
|
||||||
|
progressEnd: (done + 1) / total,
|
||||||
|
onProgress: _setProgress,
|
||||||
|
onStatus: _setStatus,
|
||||||
|
);
|
||||||
|
parts.add(summary);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('SyncController: bank tx push skipped ($error)');
|
||||||
|
_pushBankPending = true;
|
||||||
|
}
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
_setProgress(1.0);
|
_setProgress(1.0);
|
||||||
_lastSyncedAt = DateTime.now();
|
_lastSyncedAt = DateTime.now();
|
||||||
@@ -597,6 +674,7 @@ class SyncController extends ChangeNotifier {
|
|||||||
_uiNotifyThrottle?.cancel();
|
_uiNotifyThrottle?.cancel();
|
||||||
_auth.removeListener(_onAuthChanged);
|
_auth.removeListener(_onAuthChanged);
|
||||||
_bank.onAfterSuccessfulLoad = null;
|
_bank.onAfterSuccessfulLoad = null;
|
||||||
|
_bank.onPendingRemoteDeletesChanged = null;
|
||||||
_meta.onPersisted = null;
|
_meta.onPersisted = null;
|
||||||
_prefs.onFavouritesPersisted = null;
|
_prefs.onFavouritesPersisted = null;
|
||||||
_prefs.onHomeWidgetsPersisted = null;
|
_prefs.onHomeWidgetsPersisted = null;
|
||||||
|
|||||||
@@ -598,10 +598,14 @@ class PbSyncService {
|
|||||||
await _syncAppPrefs(userId, filter, prefs);
|
await _syncAppPrefs(userId, filter, prefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upserts local bank txs to PocketBase. Does **not** delete remote rows
|
/// Upserts local bank txs to PocketBase.
|
||||||
/// (partial local caches must not wipe another device).
|
///
|
||||||
|
/// Also garbage-collects remote rows that are:
|
||||||
|
/// - admin test rows (`is_manual` / `·#…` stamp) no longer local, or
|
||||||
|
/// - listed in [deleteTombs] (local delete queue).
|
||||||
Future<String> pushTransactions(
|
Future<String> pushTransactions(
|
||||||
List<BankTransaction> local, {
|
List<BankTransaction> local, {
|
||||||
|
List<BankTransaction> deleteTombs = const [],
|
||||||
void Function(double progress)? onProgress,
|
void Function(double progress)? onProgress,
|
||||||
void Function(String message)? onStatus,
|
void Function(String message)? onStatus,
|
||||||
double progressStart = 0.0,
|
double progressStart = 0.0,
|
||||||
@@ -630,10 +634,19 @@ class PbSyncService {
|
|||||||
final ops = <_BatchOp>[];
|
final ops = <_BatchOp>[];
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
|
final tombIds = {
|
||||||
|
for (final t in deleteTombs)
|
||||||
|
if (_clip(t.id, 2000).isNotEmpty) _clip(t.id, 2000),
|
||||||
|
};
|
||||||
|
|
||||||
for (final tx in local) {
|
for (final tx in local) {
|
||||||
final syncId = _clip(tx.id, 2000);
|
final syncId = _clip(tx.id, 2000);
|
||||||
if (syncId.isEmpty || !seen.add(syncId)) continue;
|
if (syncId.isEmpty || !seen.add(syncId)) continue;
|
||||||
|
// Never re-upsert a tombstoned row in the same pass.
|
||||||
|
if (tombIds.contains(syncId) ||
|
||||||
|
deleteTombs.any(tx.matchesForDelete)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
final body = <String, dynamic>{
|
final body = <String, dynamic>{
|
||||||
'user': userId,
|
'user': userId,
|
||||||
'tx_id': syncId,
|
'tx_id': syncId,
|
||||||
@@ -648,7 +661,9 @@ class PbSyncService {
|
|||||||
8,
|
8,
|
||||||
),
|
),
|
||||||
'pending': tx.pending,
|
'pending': tx.pending,
|
||||||
'is_manual': tx.isManual,
|
// Only send when true so servers without the migration still accept
|
||||||
|
// normal bank/CSV upserts. Test rows are also detected via ·# stamp.
|
||||||
|
if (tx.isManual) 'is_manual': true,
|
||||||
};
|
};
|
||||||
final remote = existing[syncId];
|
final remote = existing[syncId];
|
||||||
if (remote == null) {
|
if (remote == null) {
|
||||||
@@ -660,11 +675,25 @@ class PbSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove admin test / tombstoned rows that this device no longer has.
|
||||||
|
var deletes = 0;
|
||||||
|
for (final entry in existing.entries) {
|
||||||
|
if (seen.contains(entry.key)) continue;
|
||||||
|
final remoteTx = _bankTxFromRecord(entry.value);
|
||||||
|
final tombHit = tombIds.contains(entry.key) ||
|
||||||
|
(remoteTx != null && deleteTombs.any(remoteTx.matchesForDelete));
|
||||||
|
final orphanManual = _remoteLooksLikeManualTest(entry.value);
|
||||||
|
if (!tombHit && !orphanManual) continue;
|
||||||
|
ops.add(_BatchOp.delete(entry.value.id));
|
||||||
|
deletes++;
|
||||||
|
}
|
||||||
|
|
||||||
final creates = ops.where((o) => o.kind == _BatchOpKind.create).length;
|
final creates = ops.where((o) => o.kind == _BatchOpKind.create).length;
|
||||||
final updates = ops.where((o) => o.kind == _BatchOpKind.update).length;
|
final updates = ops.where((o) => o.kind == _BatchOpKind.update).length;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'PbSyncService transactions: local=${local.length} '
|
'PbSyncService transactions: local=${local.length} '
|
||||||
'remote=${existing.length} create=$creates update=$updates skip=$skipped',
|
'remote=${existing.length} create=$creates update=$updates '
|
||||||
|
'delete=$deletes skip=$skipped',
|
||||||
);
|
);
|
||||||
onStatus?.call(
|
onStatus?.call(
|
||||||
ops.isEmpty
|
ops.isEmpty
|
||||||
@@ -678,8 +707,13 @@ class PbSyncService {
|
|||||||
onStatus: onStatus,
|
onStatus: onStatus,
|
||||||
);
|
);
|
||||||
return 'Bank txs: local ${local.length} → remote '
|
return 'Bank txs: local ${local.length} → remote '
|
||||||
'${existing.length + creates} '
|
'${existing.length + creates - deletes} '
|
||||||
'(+$creates ~$updates · $skipped unchanged)';
|
'(+$creates ~$updates -$deletes · $skipped unchanged)';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _remoteLooksLikeManualTest(RecordModel r) {
|
||||||
|
if (r.getBoolValue('is_manual')) return true;
|
||||||
|
return RegExp(r' ·#\d+$').hasMatch(r.getStringValue('description'));
|
||||||
}
|
}
|
||||||
|
|
||||||
BankTransaction? _bankTxFromRecord(RecordModel r) {
|
BankTransaction? _bankTxFromRecord(RecordModel r) {
|
||||||
@@ -700,7 +734,8 @@ class PbSyncService {
|
|||||||
? 'EUR'
|
? 'EUR'
|
||||||
: r.getStringValue('currency'),
|
: r.getStringValue('currency'),
|
||||||
pending: r.getBoolValue('pending'),
|
pending: r.getBoolValue('pending'),
|
||||||
isManual: r.getBoolValue('is_manual'),
|
isManual: r.getBoolValue('is_manual') ||
|
||||||
|
RegExp(r' ·#\d+$').hasMatch(description),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,6 +746,8 @@ class PbSyncService {
|
|||||||
String s => double.tryParse(s) ?? 0,
|
String s => double.tryParse(s) ?? 0,
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
};
|
};
|
||||||
|
final remoteManual = remote.getBoolValue('is_manual') ||
|
||||||
|
RegExp(r' ·#\d+$').hasMatch(remote.getStringValue('description'));
|
||||||
return remote.getStringValue('date') == tx.date &&
|
return remote.getStringValue('date') == tx.date &&
|
||||||
remote.getStringValue('description') ==
|
remote.getStringValue('description') ==
|
||||||
_clip(tx.description.isEmpty ? 'Unknown' : tx.description, 4000) &&
|
_clip(tx.description.isEmpty ? 'Unknown' : tx.description, 4000) &&
|
||||||
@@ -718,29 +755,117 @@ class PbSyncService {
|
|||||||
remote.getStringValue('currency').toUpperCase() ==
|
remote.getStringValue('currency').toUpperCase() ==
|
||||||
tx.currency.toUpperCase() &&
|
tx.currency.toUpperCase() &&
|
||||||
remote.getBoolValue('pending') == tx.pending &&
|
remote.getBoolValue('pending') == tx.pending &&
|
||||||
remote.getBoolValue('is_manual') == tx.isManual;
|
remoteManual == tx.isManual;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes remote bank rows by local [BankTransaction.id] (tx_id field).
|
/// Deletes remote bank rows matching [tombstones] by tx_id or field fingerprint.
|
||||||
Future<String> deleteTransactionsByTxIds(Iterable<String> txIds) async {
|
///
|
||||||
final ids = {
|
/// Returns the tombstones that matched at least one remote row (safe to clear).
|
||||||
for (final id in txIds)
|
Future<List<BankTransaction>> deleteTransactionsMatching(
|
||||||
if (_clip(id, 2000).isNotEmpty) _clip(id, 2000),
|
List<BankTransaction> tombstones,
|
||||||
};
|
) async {
|
||||||
if (ids.isEmpty) return 'Bank txs: nothing to delete';
|
if (tombstones.isEmpty) return const [];
|
||||||
|
|
||||||
final userId = _userId;
|
final userId = _userId;
|
||||||
final filter = 'user="$userId"';
|
final filter = 'user="$userId"';
|
||||||
final existing = await _fullList('transactions', filter);
|
final existing = await _fullList('transactions', filter);
|
||||||
final ops = <_BatchOp>[
|
final matchedTombs = <BankTransaction>[];
|
||||||
for (final r in existing)
|
final ops = <_BatchOp>[];
|
||||||
if (ids.contains(r.getStringValue('tx_id'))) _BatchOp.delete(r.id),
|
final seenRecordIds = <String>{};
|
||||||
];
|
|
||||||
|
for (final r in existing) {
|
||||||
|
final remoteTx = _bankTxFromRecord(r);
|
||||||
|
if (remoteTx == null) continue;
|
||||||
|
for (final tomb in tombstones) {
|
||||||
|
final txId = r.getStringValue('tx_id');
|
||||||
|
final idHit = txId == _clip(tomb.id, 2000);
|
||||||
|
if (!idHit && !remoteTx.matchesForDelete(tomb)) continue;
|
||||||
|
if (!seenRecordIds.add(r.id)) break;
|
||||||
|
ops.add(_BatchOp.delete(r.id));
|
||||||
|
if (!matchedTombs.any(tomb.matchesForDelete)) {
|
||||||
|
matchedTombs.add(tomb);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (ops.isEmpty) {
|
if (ops.isEmpty) {
|
||||||
return 'Bank txs: 0 remote deletes (ids ${ids.length})';
|
debugPrint(
|
||||||
|
'PbSyncService deleteTransactionsMatching: 0 hits '
|
||||||
|
'for ${tombstones.length} tombstone(s)',
|
||||||
|
);
|
||||||
|
return const [];
|
||||||
}
|
}
|
||||||
await _runWriteOps('transactions', ops);
|
await _runWriteOps('transactions', ops);
|
||||||
return 'Bank txs: deleted ${ops.length} remote row(s)';
|
debugPrint(
|
||||||
|
'PbSyncService deleteTransactionsMatching: deleted ${ops.length} '
|
||||||
|
'for ${matchedTombs.length} tombstone(s)',
|
||||||
|
);
|
||||||
|
return matchedTombs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull synced delete tombstones for admin test transactions.
|
||||||
|
Future<List<BankTransaction>> pullTxDeletes({
|
||||||
|
void Function(String message)? onStatus,
|
||||||
|
}) async {
|
||||||
|
final userId = _userId;
|
||||||
|
final filter = 'user="$userId"';
|
||||||
|
onStatus?.call('Pulling deleted test txs…');
|
||||||
|
final records = await _fullList('tx_deletes', filter);
|
||||||
|
final out = <BankTransaction>[];
|
||||||
|
for (final r in records) {
|
||||||
|
final tx = _bankTxFromRecord(r);
|
||||||
|
if (tx == null) continue;
|
||||||
|
out.add(tx.copyWith(isManual: true));
|
||||||
|
}
|
||||||
|
debugPrint('PbSyncService pull: tx_deletes=${out.length}');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsert local delete tombstones. Never removes remote-only rows (union).
|
||||||
|
Future<String> pushTxDeletes(
|
||||||
|
List<BankTransaction> tombs, {
|
||||||
|
void Function(String message)? onStatus,
|
||||||
|
}) async {
|
||||||
|
if (tombs.isEmpty) return 'Tx deletes: nothing to push';
|
||||||
|
final userId = _userId;
|
||||||
|
final filter = 'user="$userId"';
|
||||||
|
onStatus?.call('Syncing deleted test txs…');
|
||||||
|
final existing = {
|
||||||
|
for (final r in await _fullList('tx_deletes', filter))
|
||||||
|
r.getStringValue('tx_id'): r,
|
||||||
|
};
|
||||||
|
|
||||||
|
final ops = <_BatchOp>[];
|
||||||
|
var skipped = 0;
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final tomb in tombs) {
|
||||||
|
final syncId = _clip(tomb.id, 2000);
|
||||||
|
if (syncId.isEmpty || !seen.add(syncId)) continue;
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'user': userId,
|
||||||
|
'tx_id': syncId,
|
||||||
|
'date': _clip(tomb.date, 32),
|
||||||
|
'description': _clip(
|
||||||
|
tomb.description.isEmpty ? 'Unknown' : tomb.description,
|
||||||
|
4000,
|
||||||
|
),
|
||||||
|
'amount': tomb.amount,
|
||||||
|
'currency': _clip(
|
||||||
|
tomb.currency.isEmpty ? 'EUR' : tomb.currency.toUpperCase(),
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
final remote = existing[syncId];
|
||||||
|
if (remote == null) {
|
||||||
|
ops.add(_BatchOp.create(body));
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _runWriteOps('tx_deletes', ops, onStatus: onStatus);
|
||||||
|
return 'Tx deletes: local ${tombs.length} (+${ops.length} · $skipped exist)';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runOne(String collection, _BatchOp op) {
|
Future<void> _runOne(String collection, _BatchOp op) {
|
||||||
@@ -907,6 +1032,12 @@ class PbSyncService {
|
|||||||
filter =
|
filter =
|
||||||
'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"';
|
'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"';
|
||||||
}
|
}
|
||||||
|
case 'tx_deletes':
|
||||||
|
final key = body['tx_id'];
|
||||||
|
if (userId != null && key is String) {
|
||||||
|
filter =
|
||||||
|
'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (filter == null) {
|
if (filter == null) {
|
||||||
throw StateError('Cannot upsert $collection without filter');
|
throw StateError('Cannot upsert $collection without filter');
|
||||||
|
|||||||
@@ -260,6 +260,53 @@ class StorageService {
|
|||||||
return _prefs.remove(StorageKeys.importedTransactions);
|
return _prefs.remove(StorageKeys.importedTransactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tombstones for locally deleted bank txs awaiting remote removal.
|
||||||
|
///
|
||||||
|
/// Supports legacy string-id lists and full transaction JSON objects.
|
||||||
|
List<BankTransaction> loadPendingTxDeletes() {
|
||||||
|
final raw = _prefs.getString(StorageKeys.pendingTxDeletes);
|
||||||
|
if (raw == null || raw.isEmpty) return const [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! List) return const [];
|
||||||
|
final out = <BankTransaction>[];
|
||||||
|
for (final e in decoded) {
|
||||||
|
if (e is Map) {
|
||||||
|
out.add(BankTransaction.fromJson(Map<String, dynamic>.from(e)));
|
||||||
|
} else if (e is String && e.isNotEmpty) {
|
||||||
|
// Legacy id-only entry — match by id string only.
|
||||||
|
final parts = e.split('|');
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
final amount = double.tryParse(parts[2]) ?? 0;
|
||||||
|
out.add(
|
||||||
|
BankTransaction(
|
||||||
|
date: parts[0],
|
||||||
|
currency: parts[1],
|
||||||
|
amount: amount,
|
||||||
|
description: parts.sublist(3).join('|'),
|
||||||
|
pending: false,
|
||||||
|
isManual: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> savePendingTxDeletes(List<BankTransaction> txs) {
|
||||||
|
if (txs.isEmpty) {
|
||||||
|
return _prefs.remove(StorageKeys.pendingTxDeletes);
|
||||||
|
}
|
||||||
|
return _prefs.setString(
|
||||||
|
StorageKeys.pendingTxDeletes,
|
||||||
|
jsonEncode([for (final tx in txs) tx.toJson()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TxMetaStoreData loadTxMetaStore() {
|
TxMetaStoreData loadTxMetaStore() {
|
||||||
final raw = _prefs.getString(StorageKeys.txMeta);
|
final raw = _prefs.getString(StorageKeys.txMeta);
|
||||||
if (raw != null && raw.isNotEmpty) {
|
if (raw != null && raw.isNotEmpty) {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ abstract final class StorageKeys {
|
|||||||
static const bankCache = 'bank_cache_v1';
|
static const bankCache = 'bank_cache_v1';
|
||||||
static const importedTransactions = 'imported_txs_v1';
|
static const importedTransactions = 'imported_txs_v1';
|
||||||
|
|
||||||
|
/// Local bank tx_ids queued for PocketBase deletion (JSON string list).
|
||||||
|
static const pendingTxDeletes = 'pending_tx_deletes_v1';
|
||||||
|
|
||||||
/// Serialized PocketBase auth token + user record (separate from tx data).
|
/// Serialized PocketBase auth token + user record (separate from tx data).
|
||||||
static const pocketBaseAuth = 'pb_auth_v1';
|
static const pocketBaseAuth = 'pb_auth_v1';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/// PocketBase migration: `tx_deletes` — synced tombstones for deleted admin
|
||||||
|
/// test transactions so another device cannot push them back into `transactions`.
|
||||||
|
///
|
||||||
|
/// Apply on the server:
|
||||||
|
///
|
||||||
|
/// sudo cp this_file /opt/pocketbase/pb_migrations/
|
||||||
|
/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js
|
||||||
|
/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up
|
||||||
|
/// sudo systemctl restart pocketbase
|
||||||
|
migrate((app) => {
|
||||||
|
const users = app.findCollectionByNameOrId("usersapp");
|
||||||
|
|
||||||
|
try {
|
||||||
|
app.findCollectionByNameOrId("tx_deletes");
|
||||||
|
return;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const ownerRules = {
|
||||||
|
listRule: "user = @request.auth.id",
|
||||||
|
viewRule: "user = @request.auth.id",
|
||||||
|
createRule: "@request.auth.id != '' && user = @request.auth.id",
|
||||||
|
updateRule: "user = @request.auth.id",
|
||||||
|
deleteRule: "user = @request.auth.id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const txDeletes = new Collection({
|
||||||
|
type: "base",
|
||||||
|
name: "tx_deletes",
|
||||||
|
...ownerRules,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "relation",
|
||||||
|
name: "user",
|
||||||
|
required: true,
|
||||||
|
collectionId: users.id,
|
||||||
|
cascadeDelete: true,
|
||||||
|
maxSelect: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Clipped BankTransaction.id
|
||||||
|
type: "text",
|
||||||
|
name: "tx_id",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "date",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 32,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "description",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 4000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "number",
|
||||||
|
name: "amount",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "currency",
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
"CREATE UNIQUE INDEX idx_tx_deletes_user_tx ON tx_deletes (user, tx_id)",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
app.save(txDeletes);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
app.delete(app.findCollectionByNameOrId("tx_deletes"));
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user