Compare commits

...

2 Commits

Author SHA1 Message Date
Andrew Lalis c0542900be Added initial no-op scheduled task for creating drafts from recurring transactions.
Build Web App / build-and-deploy (push) Successful in 19s Details
Build and Test API / build-and-deploy (push) Successful in 1m51s Details
2026-06-30 15:32:44 -04:00
Andrew Lalis 6280ab7cfb Added recurring transaction API implementation and UI elements. 2026-06-30 15:17:09 -04:00
16 changed files with 732 additions and 371 deletions

View File

@ -4,6 +4,7 @@
], ],
"copyright": "Copyright © 2024, Andrew Lalis", "copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": { "dependencies": {
"cronexp": ">=0.1.0-beta3 <0.2.0-0",
"d2sqlite3": "~>1.0", "d2sqlite3": "~>1.0",
"handy-http-starter": "~>1.6", "handy-http-starter": "~>1.6",
"jwt4d": "~>0.0.2", "jwt4d": "~>0.0.2",

View File

@ -2,6 +2,7 @@
"fileVersion": 1, "fileVersion": 1,
"versions": { "versions": {
"asdf": "0.8.0", "asdf": "0.8.0",
"cronexp": "0.1.0-beta3",
"d2sqlite3": "1.0.0", "d2sqlite3": "1.0.0",
"dxml": "0.4.5", "dxml": "0.4.5",
"handy-http-data": "1.3.2", "handy-http-data": "1.3.2",

View File

@ -54,6 +54,7 @@ interface ProfileDataSource {
TransactionTagRepository getTransactionTagRepository(); TransactionTagRepository getTransactionTagRepository();
TransactionRepository getTransactionRepository(); TransactionRepository getTransactionRepository();
TransactionDraftRepository getTransactionDraftRepository(); TransactionDraftRepository getTransactionDraftRepository();
RecurringTransactionRepository getRecurringTransactionRepository();
AnalyticsRepository getAnalyticsRepository(); AnalyticsRepository getAnalyticsRepository();
@ -97,6 +98,9 @@ version(unittest) {
TransactionDraftRepository getTransactionDraftRepository() { TransactionDraftRepository getTransactionDraftRepository() {
throw new Exception("Not implemented"); throw new Exception("Not implemented");
} }
RecurringTransactionRepository getRecurringTransactionRepository() {
throw new Exception("Not implemented");
}
AnalyticsRepository getAnalyticsRepository() { AnalyticsRepository getAnalyticsRepository() {
throw new Exception("Not implemented"); throw new Exception("Not implemented");
} }

View File

@ -216,6 +216,7 @@ class SqliteProfileDataSource : ProfileDataSource {
TransactionTagRepository transactionTagRepo; TransactionTagRepository transactionTagRepo;
TransactionRepository transactionRepo; TransactionRepository transactionRepo;
TransactionDraftRepository transactionDraftRepo; TransactionDraftRepository transactionDraftRepo;
RecurringTransactionRepository recurringTransactionRepo;
AnalyticsRepository analyticsRepo; AnalyticsRepository analyticsRepo;
this(string path) { this(string path) {
@ -305,6 +306,13 @@ class SqliteProfileDataSource : ProfileDataSource {
return transactionDraftRepo; return transactionDraftRepo;
} }
RecurringTransactionRepository getRecurringTransactionRepository() {
if (recurringTransactionRepo is null) {
recurringTransactionRepo = new SqliteRecurringTransactionRepository(db);
}
return recurringTransactionRepo;
}
AnalyticsRepository getAnalyticsRepository() { AnalyticsRepository getAnalyticsRepository() {
if (analyticsRepo is null) { if (analyticsRepo is null) {
analyticsRepo = new SqliteAnalyticsRepository(db); analyticsRepo = new SqliteAnalyticsRepository(db);

View File

@ -11,6 +11,7 @@ void startScheduledJobs() {
); );
JobScheduler jobScheduler = new TaskPoolScheduler(); JobScheduler jobScheduler = new TaskPoolScheduler();
jobScheduler.addJob(() { jobScheduler.addJob(() {
// Clear old analytics data from profiles. // Clear old analytics data from profiles.
import profile.data; import profile.data;
@ -38,5 +39,32 @@ void startScheduledJobs() {
); );
}, analyticsSchedule); }, analyticsSchedule);
// Add a scheduled job to regularly check for and create recurring transaction drafts.
jobScheduler.addJob(() {
import profile.data;
import profile.data_impl_sqlite;
import profile.model;
import transaction.dto;
import transaction.data;
import util.pagination;
import std.stdio;
import std.datetime;
import cronexp;
FileSystemProfileRepository.doForAllUserProfiles((Profile profile, ProfileRepository profileRepo) {
writefln!"Recurring transaction check: %s / %s"(profile.username, profile.name);
ProfileDataSource ds = profileRepo.getDataSource(profile);
RecurringTransactionRepository rtRepo = ds.getRecurringTransactionRepository();
Page!(RecurringTransactionResponse) result = rtRepo.findAll(PageRequest.unpaged());
foreach (rt; result.items) {
writeln(rt.scheduleExpr);
DateTime now = cast(DateTime) Clock.currTime();
auto cron = CronExpr(rt.scheduleExpr);
writefln!"scheduleExpr = %s -> %s"(rt.scheduleExpr, cron.getNext(now));
// TODO: Figure out how to actually create the transactions!
}
});
}, new FixedIntervalSchedule(minutes(1), Clock.currTime(UTC())));
jobScheduler.start(); jobScheduler.start();
} }

View File

@ -216,7 +216,7 @@ private ulong getCategoryId(in ServerHttpRequest request) {
return getPathParamOrThrow!ulong(request, "categoryId"); return getPathParamOrThrow!ulong(request, "categoryId");
} }
// Drafts & Templates // Drafts & Templates & Recurring Transactions
immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("draft.id", SortDir.DESC)]); immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("draft.id", SortDir.DESC)]);
@ -264,6 +264,46 @@ void handleDeleteDraft(ref ServerHttpRequest request, ref ServerHttpResponse res
deleteDraft(ds, draftId); deleteDraft(ds, draftId);
} }
@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong/recurring-transactions")
void handleGetDraftRecurringTransactions(
ref ServerHttpRequest request,
ref ServerHttpResponse response
) {
ProfileDataSource ds = getProfileDataSource(request);
ulong draftId = getDraftId(request);
RecurringTransactionResponse[] results = getRecurringTransactionsForDraft(ds, draftId);
writeJsonBody(response, results);
}
private ulong getDraftId(in ServerHttpRequest request) { private ulong getDraftId(in ServerHttpRequest request) {
return getPathParamOrThrow!ulong(request, "draftId"); return getPathParamOrThrow!ulong(request, "draftId");
} }
immutable DEFAULT_RECURRING_TRANSACTIONS_PAGE = PageRequest(1, 10, [Sort("id", SortDir.DESC)]);
@GetMapping(PROFILE_PATH ~ "/recurring-transactions")
void handleGetRecurringTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_RECURRING_TRANSACTIONS_PAGE);
Page!RecurringTransactionResponse page = getRecurringTransactions(ds, pr);
writeJsonBody(response, page);
}
@PostMapping(PROFILE_PATH ~ "/recurring-transactions")
void handlePostRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
RecurringTransactionPayload payload = readJsonBodyAs!RecurringTransactionPayload(request);
ProfileDataSource ds = getProfileDataSource(request);
RecurringTransactionResponse result = createRecurringTransaction(ds, payload);
writeJsonBody(response, result);
}
@DeleteMapping(PROFILE_PATH ~ "/recurring-transactions/:recurringTransactionId:ulong")
void handleDeleteRecurringTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
ulong id = getRecurringTransactionId(request);
deleteRecurringTransaction(ds, id);
}
private ulong getRecurringTransactionId(in ServerHttpRequest request) {
return getPathParamOrThrow!ulong(request, "recurringTransactionId");
}

View File

@ -67,3 +67,11 @@ interface TransactionDraftRepository {
void updateTags(ulong draftId, in string[] tags); void updateTags(ulong draftId, in string[] tags);
string[] findAllTags(); string[] findAllTags();
} }
interface RecurringTransactionRepository {
Page!RecurringTransactionResponse findAll(in PageRequest pr);
RecurringTransactionResponse[] findAllByDraftId(ulong draftId);
Optional!RecurringTransactionResponse findById(ulong id);
RecurringTransactionResponse insert(in RecurringTransactionPayload data);
void deleteById(ulong id);
}

View File

@ -913,3 +913,55 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository {
} }
} }
} }
class SqliteRecurringTransactionRepository : RecurringTransactionRepository {
private Database db;
this(Database db) {
this.db = db;
}
Page!RecurringTransactionResponse findAll(in PageRequest pr) {
string query = "SELECT * FROM recurring_transaction " ~ pr.toSql();
RecurringTransactionResponse[] results = util.sqlite.findAll(db, query, &parseRecurringTransaction);
ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM recurring_transaction");
return Page!(RecurringTransactionResponse).of(results, pr, totalCount);
}
RecurringTransactionResponse[] findAllByDraftId(ulong draftId) {
string query = "SELECT * FROM recurring_transaction WHERE draft_id = ? ORDER BY id";
return util.sqlite.findAll(db, query, &parseRecurringTransaction, draftId);
}
Optional!RecurringTransactionResponse findById(ulong id) {
return util.sqlite.findOne(
db,
"SELECT * FROM recurring_transaction WHERE id = ?",
&parseRecurringTransaction,
id
);
}
RecurringTransactionResponse insert(in RecurringTransactionPayload data) {
util.sqlite.update(
db,
"INSERT INTO recurring_transaction " ~
"(draft_id, schedule_expr) " ~
"VALUES (?, ?)",
data.draftId,
data.scheduleExpr
);
return findById(db.lastInsertRowid()).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.deleteById(db, "recurring_transaction", id);
}
private static RecurringTransactionResponse parseRecurringTransaction(Row row) {
return RecurringTransactionResponse(
row.peek!ulong(0),
row.peek!ulong(1),
row.peek!string(2)
);
}
}

View File

@ -205,3 +205,16 @@ struct TransactionDraftPayload {
Optional!ulong categoryId; Optional!ulong categoryId;
} }
} }
// Recurring Transaction stuff:
struct RecurringTransactionPayload {
ulong draftId;
string scheduleExpr;
}
struct RecurringTransactionResponse {
ulong id;
ulong draftId;
string scheduleExpr;
}

View File

@ -530,3 +530,24 @@ private void updateDraftAttachments(
attachmentRepo.remove(idToRemove); attachmentRepo.remove(idToRemove);
} }
} }
// Recurring Transactions:
Page!RecurringTransactionResponse getRecurringTransactions(ProfileDataSource ds, PageRequest pr) {
return ds.getRecurringTransactionRepository().findAll(pr);
}
RecurringTransactionResponse createRecurringTransaction(ProfileDataSource ds, in RecurringTransactionPayload payload) {
return ds.getRecurringTransactionRepository()
.insert(payload);
}
void deleteRecurringTransaction(ProfileDataSource ds, ulong id) {
ds.getRecurringTransactionRepository()
.deleteById(id);
}
RecurringTransactionResponse[] getRecurringTransactionsForDraft(ProfileDataSource ds, ulong draftId) {
return ds.getRecurringTransactionRepository()
.findAllByDraftId(draftId);
}

View File

@ -225,6 +225,8 @@ CREATE TABLE history_item_linked_journal_entry (
); );
-- Drafts / Templates / Recurring Transactions -- Drafts / Templates / Recurring Transactions
-- Generally, draft tables are copies of their normal transaction counterparts,
-- with looser nullability constraints in some cases.
CREATE TABLE transaction_draft ( CREATE TABLE transaction_draft (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,

View File

@ -25,6 +25,7 @@
"@idle-observer/vue3": "^0.2.0", "@idle-observer/vue3": "^0.2.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"cron-parser": "^5.6.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",

View File

@ -199,6 +199,17 @@ export interface TransactionDraftPayload {
attachmentIdsToRemove: number[] attachmentIdsToRemove: number[]
} }
export interface RecurringTransactionPayload {
draftId: number
scheduleExpr: string
}
export interface RecurringTransactionResponse {
id: number
draftId: number
scheduleExpr: string
}
export class TransactionApiClient extends ApiClient { export class TransactionApiClient extends ApiClient {
readonly path: string readonly path: string
@ -385,4 +396,24 @@ export class TransactionApiClient extends ApiClient {
deleteDraft(id: number): Promise<void> { deleteDraft(id: number): Promise<void> {
return super.delete(this.path + '/transaction-drafts/' + id) return super.delete(this.path + '/transaction-drafts/' + id)
} }
getRecurringTransactionsForDraft(draftId: number): Promise<RecurringTransactionResponse[]> {
return super.getJson(`${this.path}/transaction-drafts/${draftId}/recurring-transactions`)
}
getRecurringTransactions(
paginationOptions: PageRequest | undefined = undefined,
): Promise<Page<RecurringTransactionResponse>> {
return super.getJsonPage(this.path + '/recurring-transactions', paginationOptions)
}
createRecurringTransaction(
data: RecurringTransactionPayload,
): Promise<RecurringTransactionResponse> {
return super.postJson(this.path + '/recurring-transactions', data)
}
deleteRecurringTransaction(id: number): Promise<void> {
return super.delete(`${this.path}/recurring-transactions/${id}`)
}
} }

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import {
TransactionApiClient,
type RecurringTransactionPayload,
type RecurringTransactionResponse,
} from '@/api/transaction'
import ModalWrapper from './common/ModalWrapper.vue'
import { ref, useTemplateRef, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import AppForm from './common/form/AppForm.vue'
import FormGroup from './common/form/FormGroup.vue'
import FormControl from './common/form/FormControl.vue'
import AppButton from './common/AppButton.vue'
import { getSelectedProfile } from '@/api/profile.ts'
const route = useRoute()
const props = defineProps<{ draftId: number }>()
const modal = useTemplateRef('modal')
const savedTxn: Ref<RecurringTransactionResponse | undefined> = ref()
// Form Data:
const dayOfMonth: Ref<number> = ref(1)
async function show(): Promise<RecurringTransactionResponse | undefined> {
if (!modal.value) return undefined
savedTxn.value = undefined
const result = await modal.value.show()
if (result === 'saved') {
return savedTxn.value
}
return undefined
}
async function addRecurringTransaction() {
const payload: RecurringTransactionPayload = {
draftId: props.draftId,
scheduleExpr: `0 0 0 ${dayOfMonth.value} * *`,
}
const api = new TransactionApiClient(getSelectedProfile(route))
try {
savedTxn.value = await api.createRecurringTransaction(payload)
modal.value?.close('saved')
} catch (err) {
console.error(err)
savedTxn.value = undefined
}
}
defineExpose({ show })
</script>
<template>
<ModalWrapper ref="modal">
<template v-slot:default>
<h2>Add Recurring Transaction</h2>
<p>
Use this template to add a <em>recurring transaction</em>. According to the interval you
specify below, Finnow will create a new draft using this template each time, which you can
then review and submit.
</p>
<p>
Each time a new draft is created from this template for recurring transactions, the new
draft's timestamp will be set to the current date and time.
</p>
<AppForm>
<h3>Monthly Schedule</h3>
<FormGroup>
<FormControl
label="Day of Month"
hint="Specify on which day of the month this transaction occurs."
>
<input
type="number"
step="1"
min="1"
max="31"
v-model="dayOfMonth"
/>
</FormControl>
</FormGroup>
</AppForm>
</template>
<template v-slot:buttons>
<AppButton @click="addRecurringTransaction()">Add</AppButton>
<AppButton
button-style="secondary"
@click="modal?.close()"
>Cancel</AppButton
>
</template>
</ModalWrapper>
</template>

View File

@ -2,25 +2,34 @@
import { ApiError } from '@/api/base' import { ApiError } from '@/api/base'
import { formatMoney } from '@/api/data' import { formatMoney } from '@/api/data'
import { getSelectedProfile } from '@/api/profile' import { getSelectedProfile } from '@/api/profile'
import { TransactionApiClient, type TransactionDraftResponse } from '@/api/transaction' import {
TransactionApiClient,
type RecurringTransactionResponse,
type TransactionDraftResponse,
} from '@/api/transaction'
import AppButton from '@/components/common/AppButton.vue' import AppButton from '@/components/common/AppButton.vue'
import AppPage from '@/components/common/AppPage.vue' import AppPage from '@/components/common/AppPage.vue'
import CategoryLabel from '@/components/CategoryLabel.vue' import CategoryLabel from '@/components/CategoryLabel.vue'
import PropertiesTable from '@/components/PropertiesTable.vue' import PropertiesTable from '@/components/PropertiesTable.vue'
import TagLabel from '@/components/TagLabel.vue' import TagLabel from '@/components/TagLabel.vue'
import { showAlert, showConfirm } from '@/util/alert' import { showAlert, showConfirm } from '@/util/alert'
import { computed, onMounted, ref, type Ref } from 'vue' import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
import AttachmentRow from '@/components/common/AttachmentRow.vue' import AttachmentRow from '@/components/common/AttachmentRow.vue'
import LineItemCard from '@/components/LineItemCard.vue' import LineItemCard from '@/components/LineItemCard.vue'
import AppBadge from '@/components/common/AppBadge.vue' import AppBadge from '@/components/common/AppBadge.vue'
import ButtonBar from '@/components/common/ButtonBar.vue' import ButtonBar from '@/components/common/ButtonBar.vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AddRecurringTransactionModal from '@/components/AddRecurringTransactionModal.vue'
import { CronExpressionParser } from 'cron-parser'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const transactionApi = new TransactionApiClient(getSelectedProfile(route)) const transactionApi = new TransactionApiClient(getSelectedProfile(route))
const addRecurringTransactionModal = useTemplateRef('addRecurringTransactionModal')
const draft: Ref<TransactionDraftResponse | undefined> = ref() const draft: Ref<TransactionDraftResponse | undefined> = ref()
const recurringTransactions: Ref<RecurringTransactionResponse[]> = ref([])
const pageTitle = computed(() => { const pageTitle = computed(() => {
if (draft.value === undefined) return 'Transaction Draft' if (draft.value === undefined) return 'Transaction Draft'
if (draft.value.templateName !== null && draft.value.templateName.length > 0) { if (draft.value.templateName !== null && draft.value.templateName.length > 0) {
@ -31,8 +40,10 @@ const pageTitle = computed(() => {
onMounted(async () => { onMounted(async () => {
const draftId = parseInt(route.params.id as string) const draftId = parseInt(route.params.id as string)
recurringTransactions.value = []
try { try {
draft.value = await transactionApi.getDraft(draftId) draft.value = await transactionApi.getDraft(draftId)
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(draftId)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
await router.replace('/') await router.replace('/')
@ -61,6 +72,37 @@ async function onVendorClicked() {
await router.push(`/profiles/${getSelectedProfile(route)}/vendors/${draft.value.vendor.id}`) await router.push(`/profiles/${getSelectedProfile(route)}/vendors/${draft.value.vendor.id}`)
} }
} }
async function addRecurringTransaction() {
const result = await addRecurringTransactionModal.value?.show()
if (result !== undefined && draft.value) {
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
draft.value.id,
)
}
}
async function deleteRecurringTransaction(rt: RecurringTransactionResponse) {
if (!draft.value) return
await transactionApi.deleteRecurringTransaction(rt.id)
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(
draft.value.id,
)
}
function formatRecurringTransactionScheduleExpr(rt: RecurringTransactionResponse) {
const interval = CronExpressionParser.parse(rt.scheduleExpr)
const dayOfMonth = interval.fields.dayOfMonth.serialize().values[0] as number
return `The ${dayOfMonth}${getNumberAdjectiveSuffix(dayOfMonth)} day of every month`
}
function getNumberAdjectiveSuffix(n: number) {
const digit = n % 10
if (digit === 1) return 'st'
if (digit === 2) return 'nd'
if (digit === 3) return 'rd'
return 'th'
}
</script> </script>
<template> <template>
<AppPage <AppPage
@ -184,5 +226,35 @@ async function onVendorClicked() {
>Delete</AppButton >Delete</AppButton
> >
</ButtonBar> </ButtonBar>
<ButtonBar>
<AppButton
icon="repeat"
@click="addRecurringTransaction()"
>
Add Recurring Transaction
</AppButton>
</ButtonBar>
<div v-if="recurringTransactions.length > 0">
<h3>Recurring Transactions</h3>
<p>This template will be used to create new drafts using the following schedules:</p>
<ul>
<li
v-for="rt in recurringTransactions"
:key="rt.id"
>
{{ formatRecurringTransactionScheduleExpr(rt) }}
<AppButton
icon="trash"
@click="deleteRecurringTransaction(rt)"
/>
</li>
</ul>
</div>
<AddRecurringTransactionModal
ref="addRecurringTransactionModal"
:draft-id="draft.id"
/>
</AppPage> </AppPage>
</template> </template>

File diff suppressed because it is too large Load Diff