Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: recurring transactions added- migrations, ui, api updated. Closes #5 #114

Open
wants to merge 8 commits into
base: main
Choose a base branch
from

Conversation

neonshobhit
Copy link

@neonshobhit neonshobhit commented Feb 25, 2024

This Pull requests solves recurring transactions adding feature.
Closes #5

@neonshobhit neonshobhit changed the title [WIP] feature: recurring transactions added- migrations, ui, api updated #5 [WIP] feature: recurring transactions added- migrations, ui, api updated. Closes #5 Feb 25, 2024
@neonshobhit
Copy link
Author

neonshobhit commented Feb 25, 2024

If recurring transaction is selected from the drop down menu while adding an expense, that expense id will be inserted in the recurring transaction table once.
When listing for the groups, it will check if any pending recursive transaction needed to be added, if yes, then same transaction will be copied.

WIP:

  • Need to add same support while editing the expense.
  • Delete only latest transaction in the recursive transaction is yet to be supported. This will be done by giving options for archiving the expense/ delete this and all future expenses.

@neonshobhit neonshobhit changed the title [WIP] feature: recurring transactions added- migrations, ui, api updated. Closes #5 feature: recurring transactions added- migrations, ui, api updated. Closes #5 Mar 8, 2024
@neonshobhit
Copy link
Author

neonshobhit commented Mar 8, 2024

Update the PR.
Features:

  • Set the recurring expense schedule
  • While fetching Group's Expenses, checks if any pending expense exists. If yes, adds into the DB and then lists all the expenses.
  • Edit the expense schedule.
  • Delete the expense schedule (Deleting the last added expense deletes the future entries.)
  • Archive the expense which needs to be shown in history but not needed while calculating.
  • Archiving the last expense doesn't add to calculations, but will occur in the next cycle.

Screenshot 2024-03-08 at 4 49 22 PM
Screenshot 2024-03-08 at 4 49 47 PM
Screenshot 2024-03-08 at 4 49 59 PM
Screenshot 2024-03-08 at 4 50 11 PM

@neonshobhit neonshobhit force-pushed the feature/recurringtransactions branch from 42797c2 to 4001fc4 Compare March 13, 2024 19:53
@@ -100,7 +100,7 @@ export default async function GroupExpensesPage({
async function Expenses({ groupId }: { groupId: string }) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(group.id)
const expenses = (await getGroupExpenses(group.id))// .filter(e => !e.isArchive)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you revert this no-op change?

package.json Outdated
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8",
"@types/node": "^20",
"@types/node": "^20.11.20",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file (and presumably package-lock.json) is virtually unchanged except for sorting the packages and bumping the @types/node version. Is the version bump necessary? If not, I recommend removing both package.json and package-lock.json from this PR.

redirect(`/groups/${groupId}`)
}

async function archiveExpenseStateUpdateAction(state: boolean) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this stuff about archiving related to this commit "added locks to ensure only 1 user can get the group expenses created"? If no, can it be factored out of this commit / pull request and submitted separately?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No
This feature was added here for removing a recurring transction temporarily.
For example:
I have a monthly subscription of Netflix. It was there for 6 months but we cancelled for 1 month.
It will automatically add in the recurring expense section.
If I delete the transaction, future recurring transactions will not be created. Hence Archiving a transaction will result it to be there, but won't add in the split calculations.

Copy link
Author

@neonshobhit neonshobhit Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check this image for archived expense in the fetched expenses

Copy link

@rgov rgov Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I recommend simplifying the pull request to remove archived expenses for now, and propose that as a separate feature. There may be disagreement over the user experience here and it will delay acceptance of the recurring expense feature. (The word "archive" to me does not connote the behavior of the feature, for example.)

Splitwise behavior is: The most recent expense is the one marked "recurring." Older expenses can be deleted without affecting the recurrence. If you delete the most recent expense, the recurrence stops.

That makes it easy to delete transactions 1...n-1 without affecting the recurrence. But I agree it is useful to have a way to delete the most recent transaction without affecting the recurrence, too.

For version 1 of this feature I suggest just stopping the recurrence if the most recent transaction is deleted. Maybe pop up an alert warning the user about this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure. Can do. I'll raise that feature as another PR and add an alert.
Archive seemed best to me as it just serves as a historical preserved record for reference. Any other suggestion?

@@ -27,6 +35,134 @@ export async function createGroup(groupFormValues: GroupFormValues) {
})
}

export async function acquireLockRecurringTransaction(groupId:string, expenseId:string, lockId: string): Promise<RecurringTransactions[]> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate on why we need to implement locking and retrying here? What is the issue if multiple users edit a recurring expense concurrently? Some comments would go a long way towards explaining the logic.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When someone fetches group expenses, only then I'm checking if there's a pending recurring transaction. If yes, then I insert in the DB as new transaction.

Locking is necessary to avoid race condition:
if two people fetch expenses at the same time, APIs of both will result in fetching of pending transactions and inserting in DB. Hence Double insertions for the pending transaction.

Same condition will be there for update. One person is updating the transaction and other is inserting in DB.

Locking ensures consistency and correctness.

Retrying is there to retry acquiring locks after some time if it was acquired by someone else at that instant.

Copy link

@rgov rgov Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to delegate this concern to the database? Suppose each expense ID in a recurrence followed the pattern ffffff01, ffffff02, ffffff03, .... When expenses are fetched, you determine the need to create expense ffffff04. You do some kind of INSERT IF NOT EXISTS query (as you mention — createIfNotExists is what you're looking for). If there is a race and this expense already got inserted by another request, it doesn't really affect anything.

Worst case scenario I think is if, before your request completes, the expense has already been created and then subsequently updated. You might have a stale view of the totals. But it would only be a temporary issue.

Copy link
Author

@neonshobhit neonshobhit Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Umm, not.
Let's take an example:

  • 1 Jan 2024 (monthly txn) ffffff01 was added. And the group wasn't opened until now.
    If Person P1 and P2 both open it at the same moment - both will determine that they need to insert 3 new Txns. How will I verify if P1 is already adding the transaction and P2 doesn't need to?
    ffffff04 can be a copy of ffffff02 (determined by P1) or ffffff02 (determined by P2).
    For realising that someone else is working on this resource, I need to insert a reference id in DB.
    That Id can be in the same Expense table, or a new table that I created.
    New table made it easy to manage it as I can keep track which txn needs to be inserted without looking into Expense table + separation of concern.

So if lock is not there, in worst case both will insert the same records.

Another case:
P1 didn't call fetch groups (consider he didn't close tab for 4 months). P2 fetches now and he realizes he has to insert 3 new records.

P1 edits the amount from 100 to 200.
What shall happen now? Either P1 should successfully edit first and P2 shall recursively add 3 txns with updated amount OR P2 should insert all 3 txns and then only P1 should update the amount.
It shouldn't happen that P2 inserted 1 record with old amount and later 2 with updated amount.

I agree, chances of happening while edit is very hard, but it's possible that two people open the group at the same time and double inserts can happen.
I made lock so that this issue doesn't happen.

ON CONFLICT("groupId", "expenseId")
DO NOTHING;
`
await prisma.$queryRawUnsafe(query)
Copy link

@rgov rgov Apr 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know Prisma but I do not recommend building SQL queries via string interpolation. This is how SQL injection vulnerabilities arise, note the "unsafe" in the function name.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I searched for Prisma docs but there's no option for 'createIfNotExists' hence used this method. I need this functionality for avoiding race conditions in between of inserting and fetching.

I also don't like building SQL queries like this - can you please help here for the alternatives for the above requirement?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could something like this work? reference. I'm not following the issue with createIfNotExists.

const query = await prisma.$queryRaw`
  INSERT INTO "RecurringTransactions"
    ("groupId", "expenseId", "createNextAt", "lockId")
  VALUES
    (${groupId}, ${expenseId}, 0, ${lockId})
  ON CONFLICT ("groupId", "expenseId")
  DO NOTHING;
`

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean to use queryRaw instead of queryRawUnsafe?

@justcallmelarry
Copy link
Contributor

what happens if no one opens the group for over a month? will it add two recurring entries? or will it skip one?

I generally think that there should be some sort of scheduler that takes care of adding recurring expenses rather than doing some magic when people are loading the group

@neonshobhit
Copy link
Author

what happens if no one opens the group for over a month? will it add two recurring entries? or will it skip one?

It recursively adds all the pending txns, which means it won't skip any.

I generally think that there should be some sort of scheduler that takes care of adding recurring expenses rather than doing some magic when people are loading the group

Agreed. I wanted to make a scheduler only but decided against it for the following reasons:

  • Some groups can just a txn scheduler but never open that group. Since it's not requiring even logins, abuse can be high. So I felt if someone is opening the group, they are showing the intent that they want all the "pending expenses". Those who are not opening, can be avoided.
  • This is open source + allows redistribution. So, if someone wants to set Spliit up in their own server, they will need to set up crons scheduler separately if they want to use serverless. Having server in EC2 machines would be easier though, but I didn't want to limit serverless possibility becasue of just one small feature.

@rgov
Copy link

rgov commented Apr 17, 2024

The approach is appealing because it also works with a scheduler, you can simply have a cron task that polls the transaction list API, right?

@neonshobhit
Copy link
Author

Yes. But we will have to build a scheduler also. For example, we wouldn't fetch all the groups all the time. We would rather fetch only the transactions that needs to be copied (Fetch only those groups).
But yes, internally, scheduler needs to call only this function.

@neonshobhit neonshobhit force-pushed the feature/recurringtransactions branch from 4001fc4 to deae321 Compare May 25, 2024 20:08
@neonshobhit neonshobhit force-pushed the feature/recurringtransactions branch from dbffcb3 to 8a2b54d Compare May 26, 2024 08:51
@neonshobhit neonshobhit mentioned this pull request May 26, 2024
@neonshobhit
Copy link
Author

@rgov Have removed archive feature from this PR and opened another PR for the same. Resolved all the comments also.

Copy link

@rgov rgov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @neonshobhit for splitting the PR. I think this is a great feature and I hope @scastiel will provide feedback now. I have some style nitpicks here.

@@ -53,6 +53,7 @@ model Expense {
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
recurringDays Int @default(0)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation/alignment does not match the lines above.

@@ -104,7 +104,7 @@ export default async function GroupExpensesPage({
async function Expenses({ groupId }: { groupId: string }) {
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expenses = await getGroupExpenses(group.id)
const expenses = (await getGroupExpenses(group.id))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary change.

@@ -54,7 +54,6 @@ export function RecentGroupListCard({
})

const isStarred = state.starredGroups.includes(group.id)
const isArchived = state.archivedGroups.includes(group.id)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this change still be part of this PR?

@@ -51,6 +51,7 @@ import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'
import { AsyncButton } from './async-button'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import isn't used.

src/lib/api.ts Outdated
import { nanoid } from 'nanoid'
import * as z from 'zod';
// import { setTimeout } from "timers/promises";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented out code should be removed from the PR.

src/lib/api.ts Outdated
@@ -243,7 +413,69 @@ export async function getCategories() {
}

export async function getGroupExpenses(groupId: string) {
const now: number = Math.floor((new Date()).getTime() / 1000)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calculation Math.floor(date.getTime() / 1000 appears in a few places, it would probably improve readability to factor this into a separate function (and the inverse *1000 calculation) since it's not super obvious what the calculation is for, I'm guessing converting a JS timestamp from milliseconds to seconds.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Done.

@@ -241,6 +250,7 @@ export function ExpenseForm({
return onSubmit(values)
}

const recurringDays = [{ "key": "Never","value": "0"}, { "key":"weekly", "value": "7"}, {"key": "fortnightly", "value": "14"}, {"key": "monthly", "value": "30"}, {"key": "bimonthly", "value": "60"}]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This supports recurrences on an interval of days, but not other typical calendar occurrences like "on the first day of every month". I think it's fine for that to be version 1 of this feature, but rather than display "monthly" I think it should display "every 30 days" so that it doesn't lead to any surprises.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I'll add a fixes PR later to fix this issue.
Right now, changing to every 30 days.

</SelectContent>
</Select>
<FormDescription>
Select recursive days.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would not use "recursive" here, or possibly even "recurrence". "Repeat every 30 days" seems the most natural English to me.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have options like 30 days, 7 days etc. Can you please suggest other title?
or
"Repeat after:" this seems okay?

Copy link

@rgov rgov May 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Apple Calendar app uses "Repeat: <never|every day|every week|...>" which seems like a decent way to display this and a set of choices.

@@ -182,6 +187,7 @@ export function ExpenseForm({
isReimbursement: expense.isReimbursement,
documents: expense.documents,
notes: expense.notes ?? '',
recurringDays: String(expense.recurringDays),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to be a String here rather than a number?

Copy link
Author

@neonshobhit neonshobhit May 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started with Number, but I thought about this as well. How to store "1st of every month"?
Cron makes more sense. Please check here.
Most probably I will have to change every thing from days to cron - which I can add in v2 PR.

src/lib/api.ts Outdated
let recTxn;
if (!receivedLockId) return recTxn
const epochTime = Math.floor((new Date(expenseDate)).getTime() / 1000)
const nextAt: number = epochTime + (+expenseFormValues.recurringDays * 86400)
Copy link

@rgov rgov May 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates the transaction exactly 24 hours after the previous transaction. It might be ever-so-slightly better to use Math.floor(epochTime / 86400) + (1 + expenseFormValues.recurringDays) * 86400 to get midnight of the day of the next occurrence.

If there is a JS library that does these calculations, like moment.js or whatever, I would suggest using it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure.
Better way to do this is by using crons as mentioned in this comment. I can add this here or in V2 PR where I'll be changing everything from Days to crons?

@neonshobhit
Copy link
Author

@rgov Resolved comments. Please check.

@sir-argupta
Copy link

sir-argupta commented Aug 1, 2024

@neonshobhit Can you please resolve conflicts..!?

@ankit-kapur
Copy link

Would love to see this merged @neonshobhit

@neonshobhit
Copy link
Author

@sir-argupta @ankit-kapur
Apologies, Completely missed the notifications.

Anyways it's resolved and working now.
@scastiel can you please review this PR?

@trandall2
Copy link

trandall2 commented Nov 6, 2024

It looked like this PR has been inactive for a bit and has several merge conflicts that I was having issues resolving. I took inspiration from this PR for my take on the feature^.

(Note: This was also the last feature I was really waiting for to be able to switch off of SplitWise, so I have some extra motivation to try to get it through)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Recurring expenses
7 participants