Added internal_transfer attribute to transactions, migration of schemas automatically.
Build and Deploy Web App / build-and-deploy (push) Successful in 22s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m30s Details

This commit is contained in:
andrewlalis 2025-10-23 22:02:37 -04:00
parent 83db4baa5b
commit cb690702cc
15 changed files with 152 additions and 117 deletions

View File

@ -39,10 +39,12 @@ class FileSystemProfileRepository : ProfileRepository {
if (!exists(getProfilesDir())) mkdir(getProfilesDir()); if (!exists(getProfilesDir())) mkdir(getProfilesDir());
ProfileDataSource ds = new SqliteProfileDataSource(path); ProfileDataSource ds = new SqliteProfileDataSource(path);
import std.datetime; import std.datetime;
import std.conv;
auto propsRepo = ds.getPropertiesRepository(); auto propsRepo = ds.getPropertiesRepository();
propsRepo.setProperty("name", name); propsRepo.setProperty("name", name);
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString()); propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
propsRepo.setProperty("user", username); propsRepo.setProperty("user", username);
propsRepo.setProperty("database-schema-version", SCHEMA_VERSION.to!string());
return new Profile(name); return new Profile(name);
} }
@ -133,6 +135,9 @@ class SqlitePropertiesRepository : PropertiesRepository {
} }
} }
private const SCHEMA = import("sql/schema.sql");
private const uint SCHEMA_VERSION = 1;
/** /**
* An SQLite implementation of the ProfileDataSource that uses a single * An SQLite implementation of the ProfileDataSource that uses a single
* database connection to initialize various entity data access objects lazily. * database connection to initialize various entity data access objects lazily.
@ -145,7 +150,6 @@ class SqliteProfileDataSource : ProfileDataSource {
import attachment.data; import attachment.data;
import attachment.data_impl_sqlite; import attachment.data_impl_sqlite;
const SCHEMA = import("sql/schema.sql");
private const string dbPath; private const string dbPath;
Database db; Database db;
@ -159,6 +163,7 @@ class SqliteProfileDataSource : ProfileDataSource {
infoF!"Initializing database: %s"(dbPath); infoF!"Initializing database: %s"(dbPath);
db.run(SCHEMA); db.run(SCHEMA);
} }
migrateSchema();
} }
PropertiesRepository getPropertiesRepository() return scope { PropertiesRepository getPropertiesRepository() return scope {
@ -200,4 +205,32 @@ class SqliteProfileDataSource : ProfileDataSource {
void doTransaction(void delegate () dg) { void doTransaction(void delegate () dg) {
util.sqlite.doTransaction(db, dg); util.sqlite.doTransaction(db, dg);
} }
private void migrateSchema() {
import std.conv;
PropertiesRepository propsRepo = new SqlitePropertiesRepository(this.db);
uint currentVersion;
try {
currentVersion = propsRepo.findProperty("database-schema-version")
.mapIfPresent!(s => s.to!uint).orElse(0);
} catch (ConvException e) {
warn("Failed to parse database-schema-version property.", e);
currentVersion = 0;
}
if (currentVersion == SCHEMA_VERSION) return;
static const migrations = [
import("sql/migrations/1.sql")
];
static if (migrations.length != SCHEMA_VERSION) {
static assert(false, "Schema version doesn't match the list of defined migrations.");
}
while (currentVersion < SCHEMA_VERSION) {
infoF!"Migrating schema from version %d to %d."(currentVersion, currentVersion + 1);
db.run(migrations[currentVersion]);
currentVersion++;
propsRepo.setProperty("database-schema-version", currentVersion.to!string);
}
}
} }

View File

@ -264,48 +264,49 @@ class SqliteTransactionRepository : TransactionRepository {
item.amount = row.peek!ulong(3); item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5); item.description = row.peek!string(5);
item.internalTransfer = row.peek!bool(6);
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
if (!vendorId.isNull) { if (!vendorId.isNull) {
item.vendor = Optional!(TransactionDetail.Vendor).of( item.vendor = Optional!(TransactionDetail.Vendor).of(
TransactionDetail.Vendor( TransactionDetail.Vendor(
vendorId.get, vendorId.get,
row.peek!string(7), row.peek!string(8),
row.peek!string(8) row.peek!string(9)
)).toNullable; )).toNullable;
} }
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9); Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10);
if (!categoryId.isNull) { if (!categoryId.isNull) {
item.category = Optional!(TransactionDetail.Category).of( item.category = Optional!(TransactionDetail.Category).of(
TransactionDetail.Category( TransactionDetail.Category(
categoryId.get, categoryId.get,
row.peek!(Nullable!ulong)(10), row.peek!(Nullable!ulong)(11),
row.peek!string(11),
row.peek!string(12), row.peek!string(12),
row.peek!string(13) row.peek!string(13),
row.peek!string(14)
)).toNullable; )).toNullable;
} }
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(14); Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15);
if (!creditedAccountId.isNull) { if (!creditedAccountId.isNull) {
item.creditedAccount = Optional!(TransactionDetail.Account).of( item.creditedAccount = Optional!(TransactionDetail.Account).of(
TransactionDetail.Account( TransactionDetail.Account(
creditedAccountId.get, creditedAccountId.get,
row.peek!string(15),
row.peek!string(16), row.peek!string(16),
row.peek!string(17) row.peek!string(17),
row.peek!string(18)
)).toNullable; )).toNullable;
} }
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(18); Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19);
if (!debitedAccountId.isNull) { if (!debitedAccountId.isNull) {
item.debitedAccount = Optional!(TransactionDetail.Account).of( item.debitedAccount = Optional!(TransactionDetail.Account).of(
TransactionDetail.Account( TransactionDetail.Account(
debitedAccountId.get, debitedAccountId.get,
row.peek!string(19),
row.peek!string(20), row.peek!string(20),
row.peek!string(21) row.peek!string(21),
row.peek!string(22)
)).toNullable; )).toNullable;
} }
string tagsStr = row.peek!string(22); string tagsStr = row.peek!string(23);
if (tagsStr !is null && tagsStr.length > 0) { if (tagsStr !is null && tagsStr.length > 0) {
import std.string : split; import std.string : split;
item.tags = tagsStr.split(","); item.tags = tagsStr.split(",");
@ -353,6 +354,7 @@ class SqliteTransactionRepository : TransactionRepository {
data.amount, data.amount,
data.currencyCode, data.currencyCode,
data.description, data.description,
data.internalTransfer,
data.vendorId, data.vendorId,
data.categoryId data.categoryId
); );
@ -378,6 +380,7 @@ class SqliteTransactionRepository : TransactionRepository {
data.amount, data.amount,
data.currencyCode, data.currencyCode,
data.description, data.description,
data.internalTransfer,
data.vendorId, data.vendorId,
data.categoryId, data.categoryId,
transactionId transactionId
@ -403,20 +406,6 @@ class SqliteTransactionRepository : TransactionRepository {
); );
} }
static Transaction parseTransaction(Row row) {
import std.typecons : Nullable;
return Transaction(
row.peek!ulong(0),
SysTime.fromISOExtString(row.peek!string(1)),
SysTime.fromISOExtString(row.peek!string(2)),
row.peek!ulong(3),
Currency.ofCode(row.peek!(string, PeekMode.slice)(4)),
row.peek!string(5),
toOptional(row.peek!(Nullable!ulong)(6)),
toOptional(row.peek!(Nullable!ulong)(7))
);
}
/** /**
* Function to parse a list of transaction list items as obtained from the * Function to parse a list of transaction list items as obtained from the
* `get_transactions.sql` query. Because there are possibly multiple rows * `get_transactions.sql` query. Because there are possibly multiple rows
@ -459,45 +448,46 @@ class SqliteTransactionRepository : TransactionRepository {
item.amount = row.peek!ulong(3); item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5); item.description = row.peek!string(5);
item.internalTransfer = row.peek!bool(6);
// Read the nullable Vendor information. // Read the nullable Vendor information.
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7);
if (!vendorId.isNull) { if (!vendorId.isNull) {
string vendorName = row.peek!string(7); string vendorName = row.peek!string(8);
item.vendor = Optional!(TransactionsListItem.Vendor).of( item.vendor = Optional!(TransactionsListItem.Vendor).of(
TransactionsListItem.Vendor(vendorId.get, vendorName)); TransactionsListItem.Vendor(vendorId.get, vendorName));
} }
// Read the nullable Category information. // Read the nullable Category information.
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8); Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9);
if (!categoryId.isNull) { if (!categoryId.isNull) {
string categoryName = row.peek!string(9); string categoryName = row.peek!string(10);
string categoryColor = row.peek!string(10); string categoryColor = row.peek!string(11);
item.category = Optional!(TransactionsListItem.Category).of( item.category = Optional!(TransactionsListItem.Category).of(
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
} }
// Read the nullable creditedAccount. // Read the nullable creditedAccount.
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11); Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12);
if (!creditedAccountId.isNull) { if (!creditedAccountId.isNull) {
ulong id = creditedAccountId.get; ulong id = creditedAccountId.get;
string name = row.peek!string(12); string name = row.peek!string(13);
string type = row.peek!string(13); string type = row.peek!string(14);
string suffix = row.peek!string(14); string suffix = row.peek!string(15);
item.creditedAccount = Optional!(TransactionsListItem.Account).of( item.creditedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix)); TransactionsListItem.Account(id, name, type, suffix));
} }
// Read the nullable debitedAccount. // Read the nullable debitedAccount.
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15); Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16);
if (!debitedAccountId.isNull) { if (!debitedAccountId.isNull) {
ulong id = debitedAccountId.get; ulong id = debitedAccountId.get;
string name = row.peek!string(16); string name = row.peek!string(17);
string type = row.peek!string(17); string type = row.peek!string(18);
string suffix = row.peek!string(18); string suffix = row.peek!string(19);
item.debitedAccount = Optional!(TransactionsListItem.Account).of( item.debitedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix)); TransactionsListItem.Account(id, name, type, suffix));
} }
} }
// Read multi-row properties, like tags, to the current item. // Read multi-row properties, like tags, to the current item.
string tag = row.peek!string(19); string tag = row.peek!string(20);
if (tag !is null) { if (tag !is null) {
item.tags ~= tag; item.tags ~= tag;
} }
@ -509,56 +499,6 @@ class SqliteTransactionRepository : TransactionRepository {
return app[]; return app[];
} }
static TransactionsListItem parseTransactionsListItem(Row row) {
TransactionsListItem item;
item.id = row.peek!ulong(0);
item.timestamp = row.peek!string(1);
item.addedAt = row.peek!string(2);
item.amount = row.peek!ulong(3);
item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4));
item.description = row.peek!string(5);
Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6);
if (!vendorId.isNull) {
string vendorName = row.peek!string(7);
item.vendor = Optional!(TransactionsListItem.Vendor).of(
TransactionsListItem.Vendor(vendorId.get, vendorName));
}
Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8);
if (!categoryId.isNull) {
string categoryName = row.peek!string(9);
string categoryColor = row.peek!string(10);
item.category = Optional!(TransactionsListItem.Category).of(
TransactionsListItem.Category(categoryId.get, categoryName, categoryColor));
}
Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11);
if (!creditedAccountId.isNull) {
ulong id = creditedAccountId.get;
string name = row.peek!string(12);
string type = row.peek!string(13);
string suffix = row.peek!string(14);
item.creditedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15);
if (!debitedAccountId.isNull) {
ulong id = debitedAccountId.get;
string name = row.peek!string(16);
string type = row.peek!string(17);
string suffix = row.peek!string(18);
item.debitedAccount = Optional!(TransactionsListItem.Account).of(
TransactionsListItem.Account(id, name, type, suffix));
}
string tagsStr = row.peek!string(19);
if (tagsStr !is null && tagsStr.length > 0) {
import std.string : split;
item.tags = tagsStr.split(",");
} else {
item.tags = [];
}
return item;
}
private void insertLineItems(ulong transactionId, in AddTransactionPayload data) { private void insertLineItems(ulong transactionId, in AddTransactionPayload data) {
foreach (size_t idx, lineItem; data.lineItems) { foreach (size_t idx, lineItem; data.lineItems) {
util.sqlite.update( util.sqlite.update(
@ -595,6 +535,7 @@ class SqliteTransactionRepository : TransactionRepository {
.select("txn.amount") .select("txn.amount")
.select("txn.currency") .select("txn.currency")
.select("txn.description") .select("txn.description")
.select("txn.internal_transfer")
.select("txn.vendor_id") .select("txn.vendor_id")
.select("vendor.name") .select("vendor.name")
.select("txn.category_id") .select("txn.category_id")

View File

@ -17,6 +17,7 @@ struct TransactionsListItem {
ulong amount; ulong amount;
Currency currency; Currency currency;
string description; string description;
bool internalTransfer;
@serdeTransformOut!serializeOptional @serdeTransformOut!serializeOptional
Optional!Vendor vendor; Optional!Vendor vendor;
@serdeTransformOut!serializeOptional @serdeTransformOut!serializeOptional
@ -54,6 +55,7 @@ struct TransactionDetail {
ulong amount; ulong amount;
Currency currency; Currency currency;
string description; string description;
bool internalTransfer;
Nullable!Vendor vendor; Nullable!Vendor vendor;
Nullable!Category category; Nullable!Category category;
Nullable!Account creditedAccount; Nullable!Account creditedAccount;
@ -98,6 +100,7 @@ struct AddTransactionPayload {
ulong amount; ulong amount;
string currencyCode; string currencyCode;
string description; string description;
bool internalTransfer;
Nullable!ulong vendorId; Nullable!ulong vendorId;
Nullable!ulong categoryId; Nullable!ulong categoryId;
Nullable!ulong creditedAccountId; Nullable!ulong creditedAccountId;

View File

@ -67,6 +67,25 @@ void applyFilters(ref QueryBuilder qb, in ServerHttpRequest request) {
} }
} }
// Boolean filter for internal transfer.
if (request.hasParam("internal-transfer")) {
string value = request.getParamValues("internal-transfer")[0]
.strip()
.toUpper();
Optional!bool internalTransferFilter = Optional!bool.empty();
if (value == "Y" || value == "YES" || value == "TRUE" || value == "1") {
internalTransferFilter = Optional!bool.of(true);
} else if (value == "N" || value == "NO" || value == "FALSE" || value == "0") {
internalTransferFilter = Optional!bool.of(false);
}
if (!internalTransferFilter.isNull) {
qb.where("txn.internal_transfer = ?");
qb.withArgBinding((ref stmt, ref idx) {
stmt.bind(idx++, internalTransferFilter.value);
});
}
}
// Textual search query: // Textual search query:
if (request.hasParam("q")) { if (request.hasParam("q")) {
string searchQuery = request.getParamValues!string("q")[0]; string searchQuery = request.getParamValues!string("q")[0];

View File

@ -5,6 +5,7 @@ txn.added_at AS added_at,
txn.amount AS amount, txn.amount AS amount,
txn.currency AS currency, txn.currency AS currency,
txn.description AS description, txn.description AS description,
txn.internal_transfer AS internal_transfer,
txn.vendor_id AS vendor_id, txn.vendor_id AS vendor_id,
vendor.name AS vendor_name, vendor.name AS vendor_name,

View File

@ -4,6 +4,7 @@ INSERT INTO "transaction" (
amount, amount,
currency, currency,
description, description,
internal_transfer,
vendor_id, vendor_id,
category_id category_id
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)

View File

@ -0,0 +1,2 @@
ALTER TABLE "transaction"
ADD COLUMN internal_transfer BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -63,6 +63,7 @@ CREATE TABLE "transaction" (
amount INTEGER NOT NULL, amount INTEGER NOT NULL,
currency TEXT NOT NULL, currency TEXT NOT NULL,
description TEXT, description TEXT,
internal_transfer BOOLEAN NOT NULL DEFAULT FALSE,
vendor_id INTEGER, vendor_id INTEGER,
category_id INTEGER, category_id INTEGER,
CONSTRAINT fk_transaction_vendor CONSTRAINT fk_transaction_vendor

View File

@ -4,6 +4,7 @@ SET
amount = ?, amount = ?,
currency = ?, currency = ?,
description = ?, description = ?,
internal_transfer = ?,
vendor_id = ?, vendor_id = ?,
category_id = ? category_id = ?
WHERE id = ? WHERE id = ?

View File

@ -32,17 +32,6 @@ export interface TransactionCategoryTree {
depth: number depth: number
} }
export interface Transaction {
id: number
timestamp: string
addedAt: string
amount: number
currency: string
description: string
vendorId: number | null
categoryId: number | null
}
export interface TransactionsListItem { export interface TransactionsListItem {
id: number id: number
timestamp: string timestamp: string
@ -50,6 +39,7 @@ export interface TransactionsListItem {
amount: number amount: number
currency: Currency currency: Currency
description: string description: string
internalTransfer: boolean
vendor: TransactionsListItemVendor | null vendor: TransactionsListItemVendor | null
category: TransactionsListItemCategory | null category: TransactionsListItemCategory | null
creditedAccount: TransactionsListItemAccount | null creditedAccount: TransactionsListItemAccount | null
@ -82,6 +72,7 @@ export interface TransactionDetail {
amount: number amount: number
currency: Currency currency: Currency
description: string description: string
internalTransfer: boolean
vendor: TransactionVendor | null vendor: TransactionVendor | null
category: TransactionCategory | null category: TransactionCategory | null
creditedAccount: TransactionDetailAccount | null creditedAccount: TransactionDetailAccount | null
@ -111,6 +102,7 @@ export interface AddTransactionPayload {
amount: number amount: number
currencyCode: string currencyCode: string
description: string description: string
internalTransfer: boolean
vendorId: number | null vendorId: number | null
categoryId: number | null categoryId: number | null
creditedAccountId: number | null creditedAccountId: number | null

View File

@ -4,14 +4,14 @@ import { useTemplateRef, ref, computed } from 'vue'
import ModalWrapper from './common/ModalWrapper.vue' import ModalWrapper from './common/ModalWrapper.vue'
import AppButton from './common/AppButton.vue' import AppButton from './common/AppButton.vue'
const timeoutModal = useTemplateRef("timeoutModal") const timeoutModal = useTemplateRef('timeoutModal')
const secondsUntilLogout = ref(30) const secondsUntilLogout = ref(30)
const timeoutTimerId = ref<number | undefined>() const timeoutTimerId = ref<number | undefined>()
const timePhrase = computed(() => { const timePhrase = computed(() => {
if (secondsUntilLogout.value !== 1) { if (secondsUntilLogout.value !== 1) {
return secondsUntilLogout.value + " seconds" return secondsUntilLogout.value + ' seconds'
} }
return secondsUntilLogout.value + " second" return secondsUntilLogout.value + ' second'
}) })
const authStore = useAuthStore() const authStore = useAuthStore()
@ -40,8 +40,8 @@ defineExpose({ start })
<ModalWrapper ref="timeoutModal"> <ModalWrapper ref="timeoutModal">
<template v-slot:default> <template v-slot:default>
<p> <p>
You've been inactive for a while, so to ensure the safety of your You've been inactive for a while, so to ensure the safety of your personal information, you
personal information, you will be logged out in will be logged out in
<strong>{{ timePhrase }}</strong> <strong>{{ timePhrase }}</strong>
unless you click below to remain logged in. unless you click below to remain logged in.
</p> </p>

View File

@ -31,12 +31,20 @@ defineExpose({ show, close, isOpen })
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<dialog ref="dialog" class="app-modal-dialog" :id="id"> <dialog
ref="dialog"
class="app-modal-dialog"
:id="id"
>
<slot></slot> <slot></slot>
<div class="app-modal-dialog-actions"> <div class="app-modal-dialog-actions">
<slot name="buttons"> <slot name="buttons">
<AppButton button-style="secondary" @click="close()">Close</AppButton> <AppButton
button-style="secondary"
@click="close()"
>Close</AppButton
>
</slot> </slot>
</div> </div>
</dialog> </dialog>

View File

@ -51,4 +51,8 @@ defineProps<{
font-family: 'OpenSans', sans-serif; font-family: 'OpenSans', sans-serif;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
.app-form-control > label input[type='checkbox'] {
align-self: flex-start;
}
</style> </style>

View File

@ -18,10 +18,10 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const IDLE_TIMEOUT_SECONDS = 300 const IDLE_TIMEOUT_SECONDS = 300
const idleTimeoutModal = useTemplateRef("idleTimeoutModal") const idleTimeoutModal = useTemplateRef('idleTimeoutModal')
useIdleObserver({ useIdleObserver({
timeout: IDLE_TIMEOUT_SECONDS * 1000, timeout: IDLE_TIMEOUT_SECONDS * 1000,
onIdle: () => idleTimeoutModal.value?.start() onIdle: () => idleTimeoutModal.value?.start(),
}) })
const authCheckTimer: Ref<number | undefined> = ref(undefined) const authCheckTimer: Ref<number | undefined> = ref(undefined)
@ -70,16 +70,25 @@ async function checkAuth() {
<div> <div>
<header class="app-header-bar"> <header class="app-header-bar">
<div> <div>
<h1 class="app-header-text" @click="onHeaderClicked()"> <h1
class="app-header-text"
@click="onHeaderClicked()"
>
Finnow Finnow
</h1> </h1>
</div> </div>
<div> <div>
<span class="app-user-widget" @click="router.push('/me')"> <span
class="app-user-widget"
@click="router.push('/me')"
>
<font-awesome-icon icon="fa-user"></font-awesome-icon> <font-awesome-icon icon="fa-user"></font-awesome-icon>
</span> </span>
<span class="app-logout-button" @click="authStore.onUserLoggedOut()"> <span
class="app-logout-button"
@click="authStore.onUserLoggedOut()"
>
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon> <font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
</span> </span>
</div> </div>

View File

@ -85,6 +85,7 @@ const unsavedEdits = computed(() => {
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== tx.amount amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== tx.amount
const currencyChanged = currency.value?.code !== tx.currency.code const currencyChanged = currency.value?.code !== tx.currency.code
const descriptionChanged = description.value !== tx.description const descriptionChanged = description.value !== tx.description
const internalTransferChanged = internalTransfer.value !== tx.internalTransfer
const vendorChanged = vendor.value?.id !== tx.vendor?.id const vendorChanged = vendor.value?.id !== tx.vendor?.id
const categoryChanged = categoryId.value !== (tx.category?.id ?? null) const categoryChanged = categoryId.value !== (tx.category?.id ?? null)
const creditedAccountChanged = creditedAccountId.value !== (tx.creditedAccount?.id ?? null) const creditedAccountChanged = creditedAccountId.value !== (tx.creditedAccount?.id ?? null)
@ -94,6 +95,7 @@ const unsavedEdits = computed(() => {
Amount changed: ${amountChanged} Amount changed: ${amountChanged}
Currency changed: ${currencyChanged} Currency changed: ${currencyChanged}
Description changed: ${descriptionChanged} Description changed: ${descriptionChanged}
Internal Transfer changed: ${internalTransferChanged}
Vendor changed: ${vendorChanged} Vendor changed: ${vendorChanged}
Category changed: ${categoryChanged} Category changed: ${categoryChanged}
Credited account changed: ${creditedAccountChanged} Credited account changed: ${creditedAccountChanged}
@ -108,6 +110,7 @@ const unsavedEdits = computed(() => {
amountChanged || amountChanged ||
currencyChanged || currencyChanged ||
descriptionChanged || descriptionChanged ||
internalTransferChanged ||
vendorChanged || vendorChanged ||
categoryChanged || categoryChanged ||
creditedAccountChanged || creditedAccountChanged ||
@ -136,6 +139,7 @@ const timestamp = ref('')
const amount = ref(0) const amount = ref(0)
const currency: Ref<Currency | null> = ref(null) const currency: Ref<Currency | null> = ref(null)
const description = ref('') const description = ref('')
const internalTransfer = ref(false)
const vendor: Ref<TransactionVendor | null> = ref(null) const vendor: Ref<TransactionVendor | null> = ref(null)
const categoryId: Ref<number | null> = ref(null) const categoryId: Ref<number | null> = ref(null)
const creditedAccountId: Ref<number | null> = ref(null) const creditedAccountId: Ref<number | null> = ref(null)
@ -208,6 +212,7 @@ async function doSubmit() {
amount: floatMoneyToInteger(amount.value, currency.value), amount: floatMoneyToInteger(amount.value, currency.value),
currencyCode: currency.value?.code ?? '', currencyCode: currency.value?.code ?? '',
description: description.value, description: description.value,
internalTransfer: internalTransfer.value,
vendorId: vendorId, vendorId: vendorId,
categoryId: categoryId.value, categoryId: categoryId.value,
creditedAccountId: creditedAccountId.value, creditedAccountId: creditedAccountId.value,
@ -260,6 +265,7 @@ function loadValuesFromExistingTransaction(t: TransactionDetail) {
amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits) amount.value = t.amount / Math.pow(10, t.currency.fractionalDigits)
currency.value = t.currency currency.value = t.currency
description.value = t.description description.value = t.description
internalTransfer.value = t.internalTransfer
vendor.value = t.vendor ?? null vendor.value = t.vendor ?? null
categoryId.value = t.category?.id ?? null categoryId.value = t.category?.id ?? null
creditedAccountId.value = t.creditedAccount?.id ?? null creditedAccountId.value = t.creditedAccount?.id ?? null
@ -391,6 +397,20 @@ function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) {
/> />
</FormGroup> </FormGroup>
<!-- One last group for less-often used fields: -->
<FormGroup>
<FormControl
label="Internal Transfer"
hint="Mark this transaction as an internal transfer to ignore it in analytics. Useful for things like credit card payments."
>
<input
type="checkbox"
v-model="internalTransfer"
:disabled="loading"
/>
</FormControl>
</FormGroup>
<FormActions <FormActions
@cancel="doCancel()" @cancel="doCancel()"
:disabled="loading || !formValid || !unsavedEdits" :disabled="loading || !formValid || !unsavedEdits"