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

Support variable distribution period length #1691

Merged
merged 38 commits into from
Oct 3, 2023

Conversation

snowteamer
Copy link
Collaborator

Closes #1631
Closes #1662

groupjscopy.js Outdated Show resolved Hide resolved
Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

Nicely done @snowteamer!

Preliminary review ready for you! I didn't have time to do a full review this time and had to top short after reviewing periodStampGivenDate, but did my best to get what I had out to you.

frontend/model/contracts/group.js Outdated Show resolved Hide resolved
recentDate = recentDate.toISOString()
}
if (typeof recentDate !== 'string') recentDate = recentDate.toISOString()
if (!isPeriodStamp(recentDate)) throw new TypeError('must be date or isostring')
Copy link
Member

Choose a reason for hiding this comment

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

This check doesn't make sense, because recentDate is conceptually not a period stamp, it's a date, so please remove it.

You can convert a date to a periodstamp using dateToPeriodStamp if you need to.

The reason why we have these functions and must use them is because even though dates and period stamps happen to be the same thing, they are conceptually different. Treating them as the different types that they are and using the corresponding conversation functions allows us to change the format of a period stamp in the future if we need to.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and in fact I was looking for an isIsoString function, but there is no such function in time.js, and adding one would have duplicated the code in isPeriodStamp. So I just used that

frontend/model/contracts/group.js Outdated Show resolved Hide resolved
frontend/model/contracts/group.js Outdated Show resolved Hide resolved
Comment on lines 335 to 342
// Only extrapolate one period length in the future.
const extrapolatedDistributionDate = addTimeToDate(
distributionDate, distributionPeriodLength
).toISOString()
if (recentDate >= extrapolatedDistributionDate) {
return dateToPeriodStamp(extrapolatedDistributionDate)
}
return dateToPeriodStamp(distributionDate)
Copy link
Member

Choose a reason for hiding this comment

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

This code should be replaced by the previous logic we had in the periodStampGivenDate function in time.js, so that it can extrapolate multiple periods out. There's no reason not to, and we have the code for that already.

I would recommend re-introducing that function in time.js, and passing in sortedPeriodKeys to it. That would keep this function here short & sweet while having the messy logic in time.js.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Maybe you're right, but I haven't seen any use case where extrapolating more than one period in the future would be actually useful. I thought it didn't make much sense.

Copy link
Member

Choose a reason for hiding this comment

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

Say the user has their clock accidentally incorrectly set a year in the past. They will be browsing old payments they sent and using this getter to fetch the period in which the payment occurred. This getter will return the wrong value. Why should we return the wrong value when I've already written the code to return the correct value?

Copy link
Collaborator Author

@snowteamer snowteamer Sep 9, 2023

Choose a reason for hiding this comment

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

This block is only called when recentDate >= distributionDate, and I don't think distributionDate can depend on the user's clock.

Therefore in case the clock incorrectly thinks it's already several period lengths in the future, way past the distribution date, so that this block gets called, then there just won't be any matching payment to be displayed but I think that's correct.

If the user has their clock accidentally incorrectly set a year in the past, so that recentDate is one year lower than expected, then this block won't be called. The sorted periods will be searched instead, and I think the user should still be able to browse any stored payment period up to the distribution date.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, ok, that does seem to be true. Still, I don't see why we can't include a loop here that was already written, and therefore not need to worry about this returning an incorrect value.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated to handle future dates using the period length in a loop like before (re-using periodStampGivenDate in time.js)

frontend/model/contracts/group.js Outdated Show resolved Hide resolved
frontend/model/contracts/group.js Outdated Show resolved Hide resolved
frontend/model/contracts/group.js Outdated Show resolved Hide resolved
@snowteamer snowteamer force-pushed the variable-period-length branch 2 times, most recently from cf46ddc to 2a40bdd Compare August 26, 2023 09:29
@snowteamer snowteamer force-pushed the variable-period-length branch from 2a40bdd to 7035686 Compare August 28, 2023 13:15
Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

Nice work @snowteamer! Preliminary review ready!

Comment on lines 335 to 342
// Only extrapolate one period length in the future.
const extrapolatedDistributionDate = addTimeToDate(
distributionDate, distributionPeriodLength
).toISOString()
if (recentDate >= extrapolatedDistributionDate) {
return dateToPeriodStamp(extrapolatedDistributionDate)
}
return dateToPeriodStamp(distributionDate)
Copy link
Member

Choose a reason for hiding this comment

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

Say the user has their clock accidentally incorrectly set a year in the past. They will be browsing old payments they sent and using this getter to fetch the period in which the payment occurred. This getter will return the wrong value. Why should we return the wrong value when I've already written the code to return the correct value?

frontend/model/contracts/group.js Outdated Show resolved Hide resolved
frontend/model/contracts/shared/time.js Outdated Show resolved Hide resolved
frontend/model/contracts/group.js Outdated Show resolved Hide resolved
Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

Minor change requests for tonight, still reviewing...

frontend/model/contracts/group.js Show resolved Hide resolved
test/cypress/integration/group-paying.spec.js Outdated Show resolved Hide resolved
@Silver-IT Silver-IT self-requested a review September 13, 2023 05:25
@snowteamer snowteamer force-pushed the variable-period-length branch from 98d28d4 to 11b4f90 Compare September 13, 2023 07:12
Copy link
Member

@Silver-IT Silver-IT left a comment

Choose a reason for hiding this comment

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

Impressive work, @snowteamer. I didn't fully review this PR yet, but I did a preliminary review.

Comment on lines 364 to 367
for (let i = 1; i < sortedPeriodKeys.length; i++) {
if (recentDate < sortedPeriodKeys) return sortedPeriodKeys[i - 1]
}
// This should not happen
Copy link
Member

Choose a reason for hiding this comment

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

I think it should be if (recentDate < sortedPeriodKeys[i]) return sortedPeriodKeys[i - 1].

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Wow, can't believe no test caught this - thanks

frontend/model/contracts/group.js Outdated Show resolved Hide resolved
frontend/model/contracts/group.js Outdated Show resolved Hide resolved
Comment on lines 50 to 52
getStartDate () {
return this.periodStampGivenDate(new Date())
},
Copy link
Member

Choose a reason for hiding this comment

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

Is this currentPaymentPeriod?

Copy link
Collaborator Author

@snowteamer snowteamer Sep 13, 2023

Choose a reason for hiding this comment

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

It should be but currentPaymentPeriod is a bit special in that it's controlled by reactiveDate, which doesn't update in real time but in interval. So if I understand correctly it will sometimes be wrong relative to the actual current date, therefore I chose to not rely on it in other computed properties

Copy link
Member

Choose a reason for hiding this comment

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

@snowteamer Alex is right, because Vue.js is not going to automatically update the UI when new Date() moves us into a new period. This is for 2 reasons:

  1. This is a method, not a computed property
  2. Even if it were a computed property, it still wouldn't work because computed properties are only updated in response to reactive changes. Since new Date() is not a reactive property itself, and is not updated as a result of a reactive Vue.js-related change (as reactiveDate is), the computed property doesn't get updated either because that code is never re-run

Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

Another preliminary review. Will wait until everything is addressed in previous reviews before proceeding with another review

groupSortedPeriodKeys (state, getters) {
// The .sort() call might be only necessary in older browser which don't maintain object key ordering.
// A comparator function isn't required for now since our keys are ISO strings.
return Object.keys(getters.currentGroupState.paymentsByPeriod ?? {}).sort()
Copy link
Member

Choose a reason for hiding this comment

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

DRY: replace getters.currentGroupState.paymentsByPeriod ?? {} with getters.groupPeriodPayments which does the same thing.

frontend/views/containers/payments/PaymentsMixin.js Outdated Show resolved Hide resolved
frontend/views/containers/payments/PaymentsMixin.js Outdated Show resolved Hide resolved
frontend/views/containers/payments/PaymentsMixin.js Outdated Show resolved Hide resolved
frontend/views/containers/payments/MonthOverview.vue Outdated Show resolved Hide resolved
const { distributionDate, distributionPeriodLength } = getters.groupSettings
if (!distributionDate) return
// This is not always the current period stamp.
const distributionDateStamp = dateToPeriodStamp(distributionDate)
Copy link
Member

Choose a reason for hiding this comment

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

I don't think you need to do this conversion, because distributionDate is not a Date object. It is in fact a period stamp that you can use directly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I thought distributionDate was an ISO date string, which happens to also be a period stamp but is conceptually a different thing (as you explained to me). It made sense to me since distributionDate comes from the group settings, whereas "period stamp" is a more internal type I think.

So anywhere I had the chance to compare a distributionDate value against a date directly, I didn't do any conversion.
I will likely now have to add a few of such conversions there if I start to consider distributionDate as being of the "period stamp" type.

Copy link
Member

@taoeffect taoeffect Sep 15, 2023

Choose a reason for hiding this comment

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

@snowteamer I think we made a mistake in how we named that variable... it is in fact a period stamp... and it happens to be a date represented as an ISO string.

Maybe you can add a comment to the constructor to that effect? (Renaming it now is not a good idea since there are live groups running).

Also, it's a bit confusing with how we've implemented dateToPeriodStamp, which takes either a Date or a string:

export function dateToPeriodStamp (date: string | Date): string {
  return new Date(date).toISOString()
}

And yet dateFromPeriodStamp only returns a Date:

export function dateFromPeriodStamp (daystamp: string): Date {
  return new Date(daystamp)
}

But yeah, in the end, distributionDate should be treated as a period stamp :-\

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ok, thanks for explaining! 😄

Comment on lines 387 to 415
else return
}
if (periodStamp === distributionDateStamp) {
if (sortedPeriodKeys.length) {
// If the distribution date doesn't match the latest known period stamp,
// then either that stamp is for the waiting period,
// or the distribution date has just been updated.
// In both cases we can return it.
if (latestKnownStamp !== distributionDateStamp) return latestKnownStamp
// Otherwise it's a normal period, therefore substracting the period length would not be reliable.
else return sortedPeriodKeys[sortedPeriodKeys.length - 2] ?? undefined
} else {
// If no period has been stored yet, then we're in the waiting period and can do arithmetic.
return dateToPeriodStamp(addTimeToDate(distributionDate, -distributionPeriodLength))
}
}
const index = sortedPeriodKeys.indexOf(periodStamp)
if (index === -1) {
// Maybe the given stamp is wrong and has no associated period,
// but is one period length ahead of a known one.
// TODO: just return 'undefined' when sure it's always safe.
const maybePreviousStamp = dateToPeriodStamp(
addTimeToDate(dateFromPeriodStamp(periodStamp), -distributionPeriodLength)
)
return sortedPeriodKeys.includes(maybePreviousStamp) ? maybePreviousStamp : undefined
}
// If index is 0 then the caller will have to check the archive.
if (index === 0) return
return sortedPeriodKeys[index - 1]
Copy link
Member

Choose a reason for hiding this comment

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

I do not understand what is going on here or why this code is so complicated.

AFAICT this is what this function should be:

    periodBeforePeriod (state, getters) {
      return (periodStamp: string) => {
        const { distributionPeriodLength } = getters.groupSettings
        const sortedPeriods = getters.groupSortedPeriodKeys
        for (const i = sortedPeriods.length - 1; i >= 0; --i) {
          const latestPeriod = sortedPeriods[i]
          if (periodStamp >= latestPeriod) return latestPeriod
        }
        return dateToPeriodStamp(addTimeToDate(dateFromPeriodStamp(periodStamp), -distributionPeriodLength))
      }
    }

Copy link
Collaborator Author

@snowteamer snowteamer Sep 14, 2023

Choose a reason for hiding this comment

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

This code is indeed a bit complicated, in part because of the periodStamp === distributionDateStamp case, so I've tried to explain it with a a lot of comments (but failed to make it clear enough)

this is what this function should be:

If I'm not mistaken, this won't ever return undefined to defer searching to the archive code. And since every archived period could have a different length, simply substracting distributionPeriodLength is not correct in that case.
Also it doesn't do anything special for the aforementioned special case.

Would be great if that case was actually not special and we could just use a simple loop like yours, but for now the suggested code fails this assertion line 118 in group-paying.spec.js

      expect(periodBeforePeriod(onePeriodLengthAhead) === distributionDate).to.be.true

(not 100% sure why but my guess it's because in that case there exists a stored object for the waiting period but not for the current distribution date yet)

Copy link
Member

@taoeffect taoeffect Sep 15, 2023

Choose a reason for hiding this comment

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

OK, that's a good point regarding distributionDate - my code doesn't check for that because it assumes (incorrectly) that that is already stored in sortedPeriods, which you're absolutely right, it might not be.

Also, my code has a bug related to this check:

if (periodStamp >= latestPeriod) return latestPeriod

I think that should be:

if (periodStamp > latestPeriod) return latestPeriod

The code that's currently here in the PR needs to be changed for a few reasons:

  1. As discussed above, we need to avoid passing distributionDate to dateToPeriodStamp
  2. It needs to be simplified similarly to what I wrote above. Right now it's got some very questionable lines, like this:
        if (periodStamp > distributionDateStamp) {
          // Only extrapolate one period length in the future.
          const onePeriodLenghtAhead = addTimeToDate(distributionDate, distributionPeriodLength)
          if (periodStamp === dateToPeriodStamp(onePeriodLenghtAhead)) return distributionDate
          else return
        }

This is very strange. The purpose of this function is to return the period before a given period stamp. That's not what the lines above do at all. Instead they seemingly randomly add a period length to distributionDate and compare periodStamp to that, and then bails if the check fails.

We need something conceptually equivalent to the code I wrote above that:

  1. Takes into account distributionDate
  2. Has a consistent mental model for how period stamps are created and iterated forward/backward

Mental model

Our mental model is as follows:

<--- [ UNKNOWN PAST ] --- [ SORTED_KEYS ] ---- [ UNKOWN FUTURE ] --->

Here we see 3 section. There is a "known" timeline in the center, and an unknown timeline at each end.

If we are given a period stamp that is inside of [ SORTED_KEYS ], here's what iterating it looks like:

[ A ] <-> [ B ] <-> [ C ]
            ^
            |
      periodStamp

If we want to go forward in time from periodStamp pointing at [ B ] stamp, then periodAfterPeriod points us to [ C ] and periodBeforePeriod points us to [ A ].

If, however, we have this situation:

[ B ] <-> [ C ] <-> [ UNKNOWN FUTURE ]
            ^
            |
      periodStamp

Then:

  • periodAfterPeriod adds distributionPeriodLength to periodStamp and returns the result
  • periodBeforePeriod returns [ B ]

And, if we have this situation:

[ UNKNOWN PAST ] <-> [ A ] <-> [ B ]
                       ^
                       |
                   periodStamp

Then:

  • periodAfterPeriod returns [ B ]
  • periodBeforePeriod returns undefined

Note: that this is the only scenario where undefined can be returned.

Also note: in that case historicalPeriodBeforePeriod (please rename from currently called getPeriodBeforePeriod) does the exact same logic as periodBeforePeriod, except it performs the check on a version of [ SORTED_KEYS ] that includes historical payments from then archive.

In fact, because both periodBeforePeriod and historicalPeriodBeforePeriod contain line-by-line exactly the same logic, please create a shared function inside of time.js that does this logic. When historicalPeriodBeforePeriod and periodBeforePeriod call that function, they will include an array containing the [ SORTED_KEYS ].

Finally, to take into account the distributionDate, simply add in the distributionDate to the [ SORTED_KEYS ] that you pass in to the internal periodBeforePeriod inside of time.js.

And similarly, there will be a corresponding shared periodAfterPeriod in time.js that is called from both group.js and PaymentsMixin.js.

Hope that's clear! Let me know if you have any questions.

Copy link
Collaborator Author

@snowteamer snowteamer Sep 17, 2023

Choose a reason for hiding this comment

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

This is very strange. The purpose of this function is to return the period before a given period stamp.

Exactly, so if the given date is not actually a past, current or future period stamp but just a random ISO string, my assumption was that undefined had to be returned.

If that assumption shoud be dropped so that these getters behave like periodStampGivenDate, e.g. periodAfterPeriod returns B for any date between A and B not just for A, then maybe they could as well be renamed for consistency:

  • nextPeriodStampGivenDate
  • periodStampGivenDate
  • previousPeriodStampGivenDate
    (I'd rather use -forDate than -givenDate). Otherwise periodAfter/BeforeDate also sound good to me.

Another assumption in this PR, is that distributionPeriodLength is now a variable setting whose value only holds for the current period. It can no longer be safely used to iterate between existing periods, but is just a hint to initially setup a default next distribution date.
Moreover, in the waiting period the distribution date i.e. the next period stamp can be updated without updating distributionPeriodLength accordingly

frontend/model/contracts/group.js Outdated Show resolved Hide resolved
frontend/model/contracts/group.js Outdated Show resolved Hide resolved
},

// Note: 'recentDate' is a confusing name, as it can be in the future, or far in the past.
async getPeriodStampGivenDate (givenDate: string | Date) {
Copy link
Member

Choose a reason for hiding this comment

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

Rename to historicalPeriodStampGivenDate

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It can also return non-historical results, so I think it would be a bit confusing

Copy link
Member

Choose a reason for hiding this comment

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

True, but the point is that it unlike periodStampGivenDate it can give historical payment periods, so that's why it needs to be named that way. See also the mental mode comment I just posted.

Copy link
Collaborator Author

@snowteamer snowteamer Sep 18, 2023

Choose a reason for hiding this comment

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

I'm not fan of dropping the get- prefix here, as all other methods in this file are get- prefixed. It likely helps to distinguish them from computed properties. But I also think getHistoricalPeriodStampGivenDate would be a tad long...

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Should all PaymentsMixin.js methods that can return both in-memory and historical results be named that way (some currently don't)?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah feel free to make it consistent and clear 👍
(I haven't checked your latest changes as of this comment but will have a look now)

Copy link
Member

@Silver-IT Silver-IT left a comment

Choose a reason for hiding this comment

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

Finished my review. Nice work, @snowteamer

frontend/views/containers/payments/PaymentRowReceived.vue Outdated Show resolved Hide resolved
frontend/views/containers/payments/PaymentRowSent.vue Outdated Show resolved Hide resolved
@snowteamer snowteamer force-pushed the variable-period-length branch from 7f5c7ac to 0361950 Compare September 17, 2023 18:10
Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

Another preliminary review ready!

Comment on lines +322 to +325
// Append the waiting period stamp if necessary.
if (!keys.length && MAX_SAVED_PERIODS > 0) {
keys.push(dateToPeriodStamp(addTimeToDate(distributionDate, -distributionPeriodLength)))
}
Copy link
Member

Choose a reason for hiding this comment

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

Note: !keys.length should always be false because the contract constructor calls initFetchPeriodPayments, ensuring at least one entry

Copy link
Collaborator Author

@snowteamer snowteamer Sep 19, 2023

Choose a reason for hiding this comment

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

In fact, initFetchPeriodPayments calls periodStampGivenDate before doing anything else, which in turn calls groupSortedPeriodKeys

Copy link
Member

Choose a reason for hiding this comment

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

Ah, I forgot that, that's a good point. 👍

I investigated this further and surprisingly yes this code actually seems to reproduce the behavior of the code on master, which also initializes state.paymentsByPeriod with the periodstamp of the period before the first distributionDate — the never-used "waiting period". Which is kind of surprising but yeah this code seems to do the same thing so 👍

@@ -1286,8 +1276,11 @@ sbp('chelonia/defineContract', {
process ({ meta }, { state, getters }) {
const period = getters.periodStampGivenDate(meta.createdDate)
const current = getters.groupSettings?.distributionDate

if (current !== period) {
const inWaitingPeriod = !current || meta.createdDate < current
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this logic captures whether or not we're in the waiting period.

By "waiting period" I assume you mean in the period before the very first distribution has started. However, this logic doesn't check for that. We could be in in the 3rd period since the group started and this would still evaluate to true.

Whether or not we're in the first waiting period is determined really by the number of keys in getter.groupPeriodPayments. If there is only 1 key in there, and meta.createdDate < current, only then can we be considered to be inWaitingPeriod. And then you also don't need the redundant check of meta.createdDate !== current below:

Copy link
Collaborator Author

@snowteamer snowteamer Sep 20, 2023

Choose a reason for hiding this comment

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

We could be in in the 3rd period since the group started and this would still evaluate to true.

In a "normal" (not waiting) period, meta.createdDate < current should always be false, since the distribution date is when the current period started, not when the next period will start

Otherwise if I'm wrong, couldn't I just use the new getters.groupDistributionStarted(meta.createdDate) implemented by Alex?

Although I see it's not used alone somewhere else in this file, but combined with a check for the number of stored period keys:

      if (getters.groupDistributionStarted(meta.createdDate) || Object.keys(getters.groupPeriodPayments).length > 1)) {

(not sure why the getter isn't enough to ensure we're out of the waiting period)

Copy link
Member

Choose a reason for hiding this comment

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

In a "normal" (not waiting) period, meta.createdDate < current should always be false, since the distribution date is when the current period started, not when the next period will start

That's probably true.

couldn't I just use the new getters.groupDistributionStarted(meta.createdDate) implemented by Alex?

The two seem to be equivalent. You could do:

const inWaitingPeriod = !getters.groupDistributionStarted(meta.createdDate)

That would probably be best from a DRY perspective.

but combined with a check for the number of stored period keys:

Yeah I'm not sure what that's about. @Silver-IT couldn't you remove that || Object.keys(getters.groupPeriodPayments).length > 1 check? Although that might be there for a reason related to adjusting the distribution date that I don't remember.

Yeah, it could be that the distribution date was adjusted via a proposal to suddenly be in the past?

This is the code: https://github.com/okTurtles/group-income/pull/1643/files#diff-5c6306168381a1d6902fce7da1cdf9a6811e2ed4ac74d7d97bfff5145087b626R122

It seems like the proposal only allows for updating the distribution date to a date in future, starting with 1 day in the future.

However, what if by the time the proposal passes we are already ahead of that date? For example, what today is Sept. 1st, and we set the distribution date to Sept 30th, but then create a proposal to make it Sept. 2nd, but by the time the proposal passes it's already Sept 3rd?

Need to consider this.... 🤕

Comment on lines 10 to 16
const plusOnePeriodLength = (timestamp: string, periodLength: number): string => (
dateToPeriodStamp(addTimeToDate(timestamp, periodLength))
)

const minusOnePeriodLength = (timestamp: string, periodLength: number): string => (
dateToPeriodStamp(addTimeToDate(timestamp, -periodLength))
)
Copy link
Member

Choose a reason for hiding this comment

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

These look useful, you could export these too...

if (typeof date === 'string' && !isIsoString(date)) {
throw new TypeError('must be ISO string')
}
const timestamp = new Date(date).toISOString()
Copy link
Member

Choose a reason for hiding this comment

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

This is redundant when date is an iso string already. Perhaps this logic could be rewritten like so:

const timestamp = typeof date === 'string' ? date : date.toISOString()

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Indeed - This time I wanted to keep the code simple, but actually I don't like creating Date objects when not necessary

date: Date | string,
{ knownSortedStamps, periodLength }: { knownSortedStamps: string[], periodLength: number }
): Object {
if (typeof date === 'string' && !isIsoString(date)) {
Copy link
Member

Choose a reason for hiding this comment

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

This can be rewritten to remove the redundant typeof date === 'string' check like so:

if (!(isIsoString(date) || date instanceof Date)) {

(Note: for obsessive-compulsive "performance" reasons I've placed the check that's most likely to pass first, in this case the parameter is more likely to be an ISOString, so that check happens first to avoid triggering the instanceof Date check)

Copy link
Collaborator Author

@snowteamer snowteamer Sep 20, 2023

Choose a reason for hiding this comment

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

You're right, and I would even remove the date instanceof Date check if possible, since Flow annotations should already handle that.
Initially I added the isIsoString check only because string was are a superset of ISO strings

Edit: After further testing, it appears that .toISOString() throws when called on invalid date objects like new Date('foo'). So to avoid possible uncaught errors here we'll have to do a bit more validation or use a try block...

What about this:

  // `isNaN` - not `Number.isNaN` - is used here to catch `Invalid Date` objects.
  if (!isIsoString(date) && !(date instanceof Date && !isNaN(date))) {
    throw new TypeError('must be ISO string or valid Date object')
  }
  const timestamp = typeof date === 'string' ? date : date.toISOString()

Or maybe we could restrict the API to just not accept Date objects at all?

Copy link
Member

@taoeffect taoeffect Sep 20, 2023

Choose a reason for hiding this comment

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

I don't understand why you changed the condition. The one I used should work fine, and it also avoids both checks most of the time because it uses ||

The flow annotations won't always save us (type info is lost when using SBP)

Copy link
Collaborator Author

@snowteamer snowteamer Sep 20, 2023

Choose a reason for hiding this comment

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

In this suggestion I used && rather than || to avoid an extra pair or parens, and isNaN to guard against Invalid Date values. These are quite uncommon but could cause the toISOString() call to throw

Otherwise it's equivalent to if (!(isIsoString(date) || date instanceof Date))

Copy link
Member

Choose a reason for hiding this comment

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

used && rather than || to avoid an extra pair or parens

It's better to have the parens than to have an extra logical check imo

and isNaN to guard against Invalid Date values.

I really don't think that's needed

Otherwise it's equivalent to if (!(isIsoString(date) || date instanceof Date))

It's not... the || condition results in 1 check most of the time, whereas the modified && results in 3 different check every time

current = knownSortedStamps[i]
// `i + 1` is always a valid index.
next = knownSortedStamps[i + 1]
previous = knownSortedStamps[i - 1] ?? undefined
Copy link
Member

Choose a reason for hiding this comment

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

  • We don't need to ?? undefined here (undefined is returned for us)
  • Don't forget to break out of the loop

},
mounted () {
this.updateHistory()
},
methods: {
async updateHistory () {
this.history = await Promise.all(this.periods.map(async (period, i) => {
const periods = await this.getSortedPeriodKeys()
Copy link
Member

@taoeffect taoeffect Sep 19, 2023

Choose a reason for hiding this comment

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

this.getSortedPeriodKeys doesn't exist!

(Please test code before pushing to make sure it works. I might not always be a good enough of a reviewer to catch stuff like this, but the computer will always catch it if you test these cases)

Copy link
Collaborator Author

@snowteamer snowteamer Sep 19, 2023

Choose a reason for hiding this comment

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

Well I ran our test suite - I'm unfortunately no better at catching typos or old names, and need my tools to do it for me.😅

Comment on lines 50 to 52
getStartDate () {
return this.periodStampGivenDate(new Date())
},
Copy link
Member

Choose a reason for hiding this comment

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

@snowteamer Alex is right, because Vue.js is not going to automatically update the UI when new Date() moves us into a new period. This is for 2 reasons:

  1. This is a method, not a computed property
  2. Even if it were a computed property, it still wouldn't work because computed properties are only updated in response to reactive changes. Since new Date() is not a reactive property itself, and is not updated as a result of a reactive Vue.js-related change (as reactiveDate is), the computed property doesn't get updated either because that code is never re-run

Comment on lines 47 to 49
getDueDate () {
return this.dueDateForPeriod(this.currentPaymentPeriod)
},
Copy link
Member

Choose a reason for hiding this comment

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

This is supposed to be a computed property, not a method. Methods and computed properties are different. When computed properties change the UI gets updated automatically. A method cannot be bound to something as a computed property and won't result in UI updates when its return value changes. Check the Vue.js docs for more info.

Comment on lines 76 to 78
data: {
relativeTo: null
},
Copy link
Member

Choose a reason for hiding this comment

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

Please check the Vue.js documentation for how to set data properly. data cannot be an object, it must be a function that returns an object.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, good idea, my Vue.js is visibly a bit rusty...

Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

@snowteamer I thought I might be able to merge this PR but actually there are some serious errors still (testing still TBD).

Flow errors

PRs must never introduce flow errors. The following errors are present:

Running "exec:flow" (exec) task
Error -------------------------------------------------------------------- frontend/model/contracts/shared/time.js:25:47

Cannot get `Object.prototype.toString` because property `toString` [1] cannot be unbound from the context [2] where it
was defined. [method-unbinding]

   frontend/model/contracts/shared/time.js:25:47
    25|   if (!(isIsoString(date) || Object.prototype.toString.call(date) === '[object Date]')) {
                                                      ^^^^^^^^ [1]

References:
   /private/tmp/flow/flowlib_e73d01e77da1cc86_501/core.js:236:5
   236|     toString(): string;
            ^^^^^^^^^^^^^^^^^^ [2]

If you're certain this code is correct then you can use FlowFixMe comments to disable flow checking on that line (search the project for examples if you need).

Contract files are incorrect

Running grunt pin:0.1.8 --overwrite shows that the generated contracts are in fact different from the changes in this PR, indicating that they're out of date. Whenever you modify contracts that command needs to be run before pushing a commit.

Boyscoutting

I left some other comments earlier (e.g. see the comment about mounted placement, this.humanDate instead of humanDate), etc. that can now be addressed since these other changes are being requested.

While you're working on these changes I'll also be testing this PR and will get back to you with any other issues I find (if any).

Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

So far the tests have gone pretty well!

I did run into one problem though:

Screenshot 2023-09-23 at 10 49 04 AM

To reproduce:

  1. Create a $1000 group with u1 and invite u2, u3

  2. u1 income details pledging $100, u2 making $800, u3 making $780

  3. In console enter:

    sbp('gi.actions/group/forceDistributionDate', { contractID: sbp('state/vuex/state').currentGroupId })
  4. Make 1 full payment, and 1 partial payment

  5. Move to the next distribution period by running sbp('gi.actions/group/forceDistributionDate', { contractID: sbp('state/vuex/state').currentGroupId }) again

  6. As u1 check the completed payments

Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

Almost perfect!

Don't forget to always run grunt pin:0.1.8 --overwrite before pushing changes that contain modifications to contracts code (directly or indirectly through includes).

Screenshot 2023-09-26 at 9 30 21 AM

EDIT: found one more bug!

This time it appears under the RECEIVED tab for users receiving payments:

Screenshot 2023-09-26 at 9 50 04 AM

Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

Two issues:

  1. The contracts are still out of sync with the latest changes. Please run grunt pin:0.1.8 --overwrite each time any modifications are made to contracts before pushing a commit
  2. The Record Payments modal displays 2 rows for some reason in the 2nd payment period:

Screenshot 2023-10-02 at 6 54 41 PM

To reproduce:

  1. Create group with u1 and u2, and add income details for both
  2. Run sbp('gi.actions/group/forceDistributionDate', { contractID: sbp('state/vuex/state').currentGroupId }) to enter new period
  3. u1 pays u2
  4. Run sbp('gi.actions/group/forceDistributionDate', { contractID: sbp('state/vuex/state').currentGroupId }) to enter 2nd period
  5. u1 pays u2 — modal shows 2 rows for some reason

Comment on lines +77 to +78
const sent = []
const received = []
Copy link
Member

Choose a reason for hiding this comment

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

Why did this remove cloneDeep(this.ourPayments?.sent || []) and cloneDeep(this.ourPayments?.received || [])?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

To avoid collecting the same entries twice since the current period is going to be processed anyway in the below loop. Another idea would be to keep the cloneDeep calls, but then exclude the current period from the loop.

Copy link
Member

Choose a reason for hiding this comment

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

I see..

Copy link
Member

@taoeffect taoeffect left a comment

Choose a reason for hiding this comment

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

WOOHOOOOOO!!!! 😄 🎉

GREAT JOB @snowteamer !!!!!

This was a doozy of a PR but you got it working!! 😄 🥇 🏆

@taoeffect taoeffect merged commit 8d2a87c into okTurtles:master Oct 3, 2023
3 checks passed
@snowteamer snowteamer deleted the variable-period-length branch October 3, 2023 20:02
Silver-IT added a commit that referenced this pull request Nov 13, 2023
* Fix issues with chatroom, move some key logic into selectors

* Rename selector to isWaitingForKeyShare

* fix: update description for group channels

* WIP - Fix manifest not found error upon contract file change (#1703)

* Fix manifest not found error on contract file change

* Mark the gruntfile as WIP

* Remove WIP tag and code duplication

* Fix Chat UI bugs (#1712)

* fix: error of scrolling outside of the ChatMain section

* feat: jump to the latest when resize window

* feat: jump to the latest message without any delay when resize window

* fix: auto scroll 25 times a second

* File attachment to chat - 1st chunk (#1694)

* add file attachment button to the chat input-area el

* dnd mixin skeleton

* remove trailing space

* create the image preview vue component.

* task 1-2. create a preview file component for a non-image attachment

* build 'File too large' modal and pop it out when size is too large

* add a compressed svg image for attachment / make sure it can be colored via css

* add links for the drag&drop references

* display overlay image when chat-area is dragged over

* hook up DnD behavior with file-attachements method

* resolving some of Greg's CR

* another chunk of work for Greg's CR

* work on Greg's CR / multiple file attachments

* fix the test failure

* make it possible to append attachments when drag&drop is performed

* resolve the linter error

* work on Greg's CR / fix the bug where dot is displayed

* Add signedData, change message format

* Re-add validation

* Fix flow errors

* Fix OP_ATOMIC handling

* Harmonise types for signedData and encryptedData

* Proposal to update distribution date (#1643)

* feat: created a DistributionDate modal

* chore: improve translation

* feat: updated translation and added isInFirstDistributionPeriod

* feat: completed the change distribution date workfloww

* feat: restrict group members to change group settings

* feat: improve updateSettings group action

* feat: exception handler when open proposal modal directly

* fix: error to close General Proposal modal

* feat: improve DistributionDate proposal workflow

* feat: proposal Item for distribution date

* feat: simplified interactive message templates

* feat: proposal to change distribution date

* fix: cypress error with distribution updates

* feat: resolved feedback from @sebin

* feat: test case for changing distribution date

* fix: travis error regarding distribution date

* fix: disable submit button instead of close proposal modal

* fix: groupDistributionStarted using Date

* feat: simplified

* fix: init scrollDistance when switch channel

* fix: simplified payment periods config

* fix: updated translation

* chore: remove DRY issue and update translation

* feat: localized texts for proposals

* Implement inner signatures for OP_KEY_REQUEST

* Inner signatures for actions

* Add wrapper to encryptedData result

* Fix flow errors

* Fix rotate keys error when content is undefined

* Fix JSON serialization issues for incoming encrypted data

* Reintroduce toJSON, fix issue with ()

* Use additional data for encryption

* Start adding user keys to group & chatrooms

* Add direction attribute to GIMessage

* Add comment

* Simplified some of the code relating to GIMessage and signedData

* Fixed reference to headJSON

* Rudimentary support for inner signatures (missing permissions)

* Various changes:

* Light refactoring of `encryptedAction` to make it clearer
  - Actions to own own identity do not use inner signatures
* Permission checking and enforcement for inner signatures
  - Now, permissions are checked in a uniform way in a central
    location
* Add new `ourContactProfilesById` and `ourContactsById` getters

* Changes for optinally encrypting OP_KEY_*

* Fix key re-selection issue

* Should not display archived proposals from other group (#1723)

* feat: destructure archPropData

* chore: reverted style changes

* chore: changed variable name

* chore: added comments

* Create ci.yml

* Rethrow uncaught Vue errors in dev mode or CI (#1708)

* Rethrow uncaught Vue errors in dev mode or CI

* Apply review

* WIP: Use encrypted OP_KEY_* and new OP_KEY_REQUEST

* Bugfixes

* Merge PrivateDM and GroupDM (#1709)

* feat: direct messages in identity contract

* feat: add/remove getters

* feat: simplified getters

* wip: merging privateDMs and groupDMs

* fix: coding issues

* feat: UI updates for the DMs with more than 3

* fix: add member to dm

* feat: changed base branch from e2e-protocol-ricardo to e2e-protocol

* fix: error in get dm by usernames

* chore: added comments

* fix: error in function parameters declaration

* fix: inconsistent naming of members when create a new DM

* fix: redundant message rendering issue

* feat: grun tpin

* chore: removed wrong version of contracts

* feat: grunt pin:0.1.8

* feat: added comments

* chore: removed changes in chelonia

* chore: added comments

* chore: fix typo

* feat: error handling

* chore: removed unnecessary comment

* feat: improved chatroom types and added comments

* feat: simplified getter

* chore: simplified

* chore: added comments, error handling

* feat: merged DMs from inside and outside of the group

* feat: remove workflow regarding the dm visibility

* Bugfixes for new OP_KEY_SHARE

* feat: remove notifications when leave group (#1722)

* Bugfixes

* Improvements & bugfixes

chelonia.js: Use regular signedOutgoingData when possible
group.js: Fix name of selector

* Bugfixes

* Bugfixes for UI error handling. Move encryptedOutgoingData into /keyDel and /keyUpdate

* Implement some PR feedback

* Make KRS+KR atomic; disable deleting state when logging out

* Fix flow issues

* Mark contract as dirty until keys are received

* Bugfix: call group join upon login

* Add comments

* Encrypted storage

* chore: resolved conflicts

* feat: giCreateGroup waits until the user joins the group

* chore: fixed some errors

* fix: error egarding Awesome button disability

* Mark contract as dirty until keys are received

* Bugfix: call group join upon login

* Add comments

* feat: group-chat-direct-message.spec.js test passed

* Remove event listener when logging out

* Use settings for key storage instead of SessionStorage

* Refactoring: state encryption logic

* Simplify check

* Update references to session storage with app settings

* Support variable distribution period length (#1691)

* Fix Vue error in groupProposalSettings

* Remove unused paymentTotalFromUserToUser getter

* Add groupSortedPeriodKeys getter in group.js

* Update getters to support variable period length

* Add isIsoString in time.js and use it

* Support variable period length in MonthOverview.vue

* Apply review

* Add periodStampsForDate in time.js

* Add getHistoricalPaymentPeriods in PaymentMixin.js

* Rename getSortedPeriodKeys to getAllSortedPeriodKeys

* Add a few comments

* Export new helpers from time.js

* Improve validation in periodStampsForDate

* Remove occurences of 'undefined'

* Fix missing break statement

* Fix wrong getter name in SupportHistory.vue

* fixup! Improve validation in periodStampsForDate

* Restore logging in Cypress

* Fix reactivity issue in MonthOverview.vue

* Fix data() in PaymentRowReceived/Sent.vue

* Use groupDistributionStarted i/o inWaitingPeriod

* Revert change in updateDistributionDate

* Use humanStart/DueDate in MonthOverview.vue

* Simplify PaymentDetail.vue/initializeDetails

* Use mounted i/o watch in PaymentRowReceived/Sent.vue

* Fix pedantic Flow error

* Pin contracts to 0.1.8

* Move mounted() near the top

* Revert humanDate to plain import in MonthOverview.vue

* Use payment.period to fix Invalid Date errors

* Add .start field in initPaymentPeriod

* Add .end field in in-memory payment periods

* Rename getPeriodPayment to getPaymentPeriod

* Fix issue 1739

* Pin contracts

* Add test for payment in 2nd period

* Fix conditions for omitting empty key ops

* EncryptedValue: ensure that it's a string

* Refactor: logout handler

* Convert password to passwordFn to prevent logging

* Fix issue (key rotation / private channel not working after rejoin)

* Update Style-Guide.md

Added `User-facing Strings Guide`

* Update Style-Guide.md

typo

* Update Style-Guide.md

Improved translation section

* Update Style-Guide.md

* Update Style-Guide.md

* Update Style-Guide.md

* Fix: leaving private chat without being member

* Update Style-Guide.md

fix typos

* Documentation

* feat: updated test cases according to the updated invitation workflow

* Update Style-Guide.md

Consistent use of colons

* #1742 Use 'submit-button' component for asynchronous actions (#1750)

* add a todo comment for future <submit-button /> replacement

* add submit-button component to various places

* work on Greg's CR

* #1704 - Add 'Export to CSV' feature to payments table (#1724)

* add export CSV button to the table

* create ExportPaymentsModal.vue and register it

* make sure all the UI side of work are ready

* complete CSV Extraction logic

* complete the download CSV file logic

* fix the linter error

* work on Greg's feedback on the button position

* make sure 'Export CSV' button is not overlapped regardless of the screen width

* fix the linter error

* put all-period option into the dropdown

* remove the unecessary extra padding

* DRY PaymentsMixin.js

* update for Greg's CR on the PR

* fix the broken translation o the modal title

* restore package.json wtf...

* Bugfixes

* Add UI prompt to login and re-order buttons

* Rename button names

* Bugfix: null inner signing key when leaving chatroom

* Add comments explaining the innerSigning attributes

* Bugfix: race condition when joining due to skipActionProcessing

* Change bad previous head log level to warn

* Remove skipActionProcessing from respondToKeyRequests

* wip: second half

* feat: added profile-card in payments table

* [e2e-protocol] Fix the welcome screen bug in /pending-approval page (#1748)

* render welcome screen as position:fixed in /pending-approval page

* make the selector higher priority

* Fixed error to save scroll position (#1730)

* feat: direct messages in identity contract

* feat: add/remove getters

* feat: simplified getters

* wip: merging privateDMs and groupDMs

* fix: coding issues

* feat: UI updates for the DMs with more than 3

* fix: add member to dm

* feat: changed base branch from e2e-protocol-ricardo to e2e-protocol

* fix: error in get dm by usernames

* chore: added comments

* fix: error in function parameters declaration

* fix: inconsistent naming of members when create a new DM

* fix: redundant message rendering issue

* feat: grun tpin

* chore: removed wrong version of contracts

* feat: grunt pin:0.1.8

* feat: added comments

* chore: removed changes in chelonia

* chore: added comments

* chore: fix typo

* feat: error handling

* chore: removed unnecessary comment

* feat: improved chatroom types and added comments

* feat: simplified getter

* chore: simplified

* chore: added comments, error handling

* fix: error to save scroll position

* chore: removed allowDMInvite

* Bugfix: key rotation on watched contracts

* Fix message format for OP_KEY_SHARE

* Rotate PEK when a group member leaves

* Generic definition for the various DB stores

* Revert changes to frontend/model/database.js and use the identity contract ID as key

* Remove withEnv

* fix: error regarding passwordFn

* bug-fix for closing modal reacting too slowly

* Handle encrypted and unencrypted invites (accounting)

* fear: remove archived data when leave the group (#1747)

* Add return

* Improvements to respondToKeyRequests

* fix: errors in cypress test

* Make sync no-op for existing contracts

* fix: cypress issues

* fix: error after login

* Listen for login event before calling postSubmit

* Await in the login form, documentation

* Updates to postSyncOps

* Split up syncContractAndWatchKeys

* Add chelonia/private/enqueuePostSyncOps to blacklist

* Closure for storeSecretKeys to prevent logging secrets

* Remove promiseAllSettled. Bugfixes.

* Update okturtles.eventqueue

* Side-effects without await

* Fix invocation

* Make sure we sync any identity contracts we haven't synced upon login (#1763)

* feat: sync missing identity contracts after joining group

* chore: remove IIFE

* chore: resolved feedback

* Remove await on /out in internals.js

* Remove queueInvocation for /out events

* Make cannot leave if not a member error into a warning

* Show login error prompt only for errors related to the identity contract itself

* Fix issue of removing two members (error on second removal)

* Update identity.js login logic, remove spurious sideEffect check

* pin contracts

* Implement a way to set default groupId (#1773)

* feat: set default group ID which is successfully joined

* fix: recovered package.json

* Fix inability to join after sharer signs out

* Updated login error message (#1731)

* feat: make login error message more informative

* chore: added a line to log error

* fix: error of Member Removal (#1764)

* Fix infinite re-subscription due to foreign keys

* Revert "Updated login error message (#1731)"

This reverts commit cf4b7f4.

* Updated login error message (#1731)

* feat: make login error message more informative

* chore: added a line to log error

* Fix undefined in vmInvites

* preSendCheck for outgoing messages; move hooks to publishEvent

* Remove preSendCheckContract

* Remove await from call to leaveAllChatRoomsUponLeaving

---------

Co-authored-by: Ricardo Iván Vieitez Parra <[email protected]>
Co-authored-by: Greg Slepak <[email protected]>
Co-authored-by: snowteamer <[email protected]>
Co-authored-by: Sebin Song <[email protected]>
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.

Vue exception in console after leaving group Store payment periods as a doubly linked list
3 participants