WIP: Add Drafts, Templates, and Recurring Transactions #45

Draft
andrew wants to merge 18 commits from drafts into main
13 changed files with 702 additions and 371 deletions
Showing only changes of commit 6280ab7cfb - Show all commits

View File

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

View File

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

View File

@ -216,7 +216,7 @@ private ulong getCategoryId(in ServerHttpRequest request) {
return getPathParamOrThrow!ulong(request, "categoryId");
}
// Drafts & Templates
// Drafts & Templates & Recurring Transactions
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);
}
@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) {
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);
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;
}
}
// 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);
}
}
// 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
-- Generally, draft tables are copies of their normal transaction counterparts,
-- with looser nullability constraints in some cases.
CREATE TABLE transaction_draft (
id INTEGER PRIMARY KEY,

View File

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

View File

@ -199,6 +199,17 @@ export interface TransactionDraftPayload {
attachmentIdsToRemove: number[]
}
export interface RecurringTransactionPayload {
draftId: number
scheduleExpr: string
}
export interface RecurringTransactionResponse {
id: number
draftId: number
scheduleExpr: string
}
export class TransactionApiClient extends ApiClient {
readonly path: string
@ -385,4 +396,24 @@ export class TransactionApiClient extends ApiClient {
deleteDraft(id: number): Promise<void> {
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 { formatMoney } from '@/api/data'
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 AppPage from '@/components/common/AppPage.vue'
import CategoryLabel from '@/components/CategoryLabel.vue'
import PropertiesTable from '@/components/PropertiesTable.vue'
import TagLabel from '@/components/TagLabel.vue'
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 LineItemCard from '@/components/LineItemCard.vue'
import AppBadge from '@/components/common/AppBadge.vue'
import ButtonBar from '@/components/common/ButtonBar.vue'
import { useRoute, useRouter } from 'vue-router'
import AddRecurringTransactionModal from '@/components/AddRecurringTransactionModal.vue'
import { CronExpressionParser } from 'cron-parser'
const route = useRoute()
const router = useRouter()
const transactionApi = new TransactionApiClient(getSelectedProfile(route))
const addRecurringTransactionModal = useTemplateRef('addRecurringTransactionModal')
const draft: Ref<TransactionDraftResponse | undefined> = ref()
const recurringTransactions: Ref<RecurringTransactionResponse[]> = ref([])
const pageTitle = computed(() => {
if (draft.value === undefined) return 'Transaction Draft'
if (draft.value.templateName !== null && draft.value.templateName.length > 0) {
@ -31,8 +40,10 @@ const pageTitle = computed(() => {
onMounted(async () => {
const draftId = parseInt(route.params.id as string)
recurringTransactions.value = []
try {
draft.value = await transactionApi.getDraft(draftId)
recurringTransactions.value = await transactionApi.getRecurringTransactionsForDraft(draftId)
} catch (err) {
console.error(err)
await router.replace('/')
@ -61,6 +72,37 @@ async function onVendorClicked() {
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>
<template>
<AppPage
@ -184,5 +226,35 @@ async function onVendorClicked() {
>Delete</AppButton
>
</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>
</template>

File diff suppressed because it is too large Load Diff