Merge branch 'master' into 2547-normalized-amount-transaction-is-processed-again

This commit is contained in:
Alexander Friedland 2023-01-17 16:00:16 +01:00 committed by GitHub
commit aadd2149aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 683 additions and 546 deletions

View File

@ -557,7 +557,6 @@ export class ContributionResolver {
): Promise<boolean> { ): Promise<boolean> {
// acquire lock // acquire lock
const releaseLock = await TRANSACTIONS_LOCK.acquire() const releaseLock = await TRANSACTIONS_LOCK.acquire()
try { try {
const clientTimezoneOffset = getClientTimezoneOffset(context) const clientTimezoneOffset = getClientTimezoneOffset(context)
const contribution = await DbContribution.findOne(id) const contribution = await DbContribution.findOne(id)
@ -664,7 +663,6 @@ export class ContributionResolver {
} finally { } finally {
releaseLock() releaseLock()
} }
return true return true
} }

View File

@ -4,7 +4,7 @@
import { transactionLinkCode } from './TransactionLinkResolver' import { transactionLinkCode } from './TransactionLinkResolver'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { cleanDB, testEnvironment, resetToken } from '@test/helpers' import { cleanDB, testEnvironment, resetToken, resetEntity } from '@test/helpers'
import { creationFactory } from '@/seeds/factory/creation' import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index' import { creations } from '@/seeds/creation/index'
import { userFactory } from '@/seeds/factory/user' import { userFactory } from '@/seeds/factory/user'
@ -50,238 +50,340 @@ afterAll(async () => {
}) })
describe('TransactionLinkResolver', () => { describe('TransactionLinkResolver', () => {
// TODO: have this test separated into a transactionLink and a contributionLink part (if possible) describe('redeemTransactionLink', () => {
describe('redeem daily Contribution Link', () => { describe('contributionLink', () => {
const now = new Date() describe('input not valid', () => {
let contributionLink: DbContributionLink | undefined
let contribution: UnconfirmedContribution | undefined
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
})
it('has a daily contribution link in the database', async () => {
const cls = await DbContributionLink.find()
expect(cls).toHaveLength(1)
contributionLink = cls[0]
expect(contributionLink).toEqual(
expect.objectContaining({
id: expect.any(Number),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
validFrom: new Date(now.getFullYear(), 0, 1),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
cycle: 'DAILY',
maxPerCycle: 1,
totalMaxCountOfContribution: null,
maxAccountBalance: null,
minGapHours: null,
createdAt: expect.any(Date),
deletedAt: null,
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
linkEnabled: true,
amount: expect.decimalEqual(5),
maxAmountPerMonth: expect.decimalEqual(200),
}),
)
})
describe('user has pending contribution of 1000 GDD', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
const result = await mutate({
mutation: createContribution,
variables: {
amount: new Decimal(1000),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
contribution = result.data.createContribution
})
it('does not allow the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.',
),
],
})
})
})
describe('user has no pending contributions that would not allow to redeem the link', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: updateContribution,
variables: {
contributionId: contribution ? contribution.id : -1,
amount: new Decimal(800),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
})
it('allows the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
describe('after one day', () => {
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers()
setTimeout(jest.fn(), 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
}) })
}) })
afterAll(() => { it('throws error when link does not exists', async () => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
await expect( await expect(
mutate({ mutate({
mutation: redeemTransactionLink, mutation: redeemTransactionLink,
variables: { variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''), code: 'CL-123456',
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
}, },
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today', 'Creation from contribution link was not successful. Error: No contribution link found to given code: CL-123456',
), ),
], ],
}) })
}) })
it('throws error when link is not valid yet', async () => {
const now = new Date()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear() + 1, 0, 1).toISOString(),
validTo: new Date(now.getFullYear() + 1, 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link not valid yet',
),
],
})
await resetEntity(DbContributionLink)
})
it('throws error when contributionLink cycle is invalid', async () => {
const now = new Date()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'INVALID',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link has unknown cycle',
),
],
})
await resetEntity(DbContributionLink)
})
it('throws error when link is no longer valid', async () => {
const now = new Date()
const {
data: { createContributionLink: contributionLink },
} = await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear() - 1, 0, 1).toISOString(),
validTo: new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + contributionLink.code,
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link is no longer valid',
),
],
})
await resetEntity(DbContributionLink)
})
}) })
})
})
describe('transaction links list', () => { // TODO: have this test separated into a transactionLink and a contributionLink part
const variables = { describe('redeem daily Contribution Link', () => {
userId: 1, // dummy, may be replaced const now = new Date()
filters: null, let contributionLink: DbContributionLink | undefined
currentPage: 1, let contribution: UnconfirmedContribution | undefined
pageSize: 5,
}
// TODO: there is a test not cleaning up after itself! Fix it!
beforeAll(async () => {
await cleanDB()
resetToken()
})
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables,
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
describe('without admin rights', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
}) })
}) })
afterAll(async () => { it('has a daily contribution link in the database', async () => {
await cleanDB() const cls = await DbContributionLink.find()
resetToken() expect(cls).toHaveLength(1)
contributionLink = cls[0]
expect(contributionLink).toEqual(
expect.objectContaining({
id: expect.any(Number),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
validFrom: new Date(now.getFullYear(), 0, 1),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
cycle: 'DAILY',
maxPerCycle: 1,
totalMaxCountOfContribution: null,
maxAccountBalance: null,
minGapHours: null,
createdAt: expect.any(Date),
deletedAt: null,
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
linkEnabled: true,
amount: expect.decimalEqual(5),
maxAmountPerMonth: expect.decimalEqual(200),
}),
)
}) })
describe('user has pending contribution of 1000 GDD', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
const result = await mutate({
mutation: createContribution,
variables: {
amount: new Decimal(1000),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
contribution = result.data.createContribution
})
it('does not allow the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.',
),
],
})
})
})
describe('user has no pending contributions that would not allow to redeem the link', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: updateContribution,
variables: {
contributionId: contribution ? contribution.id : -1,
amount: new Decimal(800),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
})
it('allows the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
describe('after one day', () => {
beforeAll(async () => {
jest.useFakeTimers()
setTimeout(jest.fn(), 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
})
})
})
})
describe('transaction links list', () => {
const variables = {
userId: 1, // dummy, may be replaced
filters: null,
currentPage: 1,
pageSize: 5,
}
// TODO: there is a test not cleaning up after itself! Fix it!
beforeAll(async () => {
await cleanDB()
resetToken()
})
describe('unauthenticated', () => {
it('returns an error', async () => { it('returns an error', async () => {
await expect( await expect(
query({ query({
@ -296,40 +398,22 @@ describe('TransactionLinkResolver', () => {
}) })
}) })
describe('with admin rights', () => { describe('authenticated', () => {
beforeAll(async () => { describe('without admin rights', () => {
// admin 'peter@lustig.de' has to exists for 'creationFactory' beforeAll(async () => {
await userFactory(testEnv, peterLustig) user = await userFactory(testEnv, bibiBloxberg)
await mutate({
user = await userFactory(testEnv, bibiBloxberg) mutation: login,
variables.userId = user.id variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
variables.pageSize = 25 })
// bibi needs GDDs
const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await creationFactory(testEnv, bibisCreation!)
// bibis transaktion links
const bibisTransaktionLinks = transactionLinks.filter(
(transactionLink) => transactionLink.email === 'bibi@bloxberg.de',
)
for (let i = 0; i < bibisTransaktionLinks.length; i++) {
await transactionLinkFactory(testEnv, bibisTransaktionLinks[i])
}
// admin: only now log in
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
}) })
})
afterAll(async () => { afterAll(async () => {
await cleanDB() await cleanDB()
resetToken() resetToken()
}) })
describe('without any filters', () => { it('returns an error', async () => {
it('finds 6 open transaction links and no deleted or redeemed', async () => {
await expect( await expect(
query({ query({
query: listTransactionLinksAdmin, query: listTransactionLinksAdmin,
@ -337,185 +421,235 @@ describe('TransactionLinkResolver', () => {
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
data: { errors: [new GraphQLError('401 Unauthorized')],
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.not.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}), }),
) )
}) })
}) })
describe('all filters are null', () => { describe('with admin rights', () => {
it('finds 6 open transaction links and no deleted or redeemed', async () => { beforeAll(async () => {
await expect( // admin 'peter@lustig.de' has to exists for 'creationFactory'
query({ await userFactory(testEnv, peterLustig)
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withDeleted: null,
withExpired: null,
withRedeemed: null,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.not.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
describe('filter with deleted', () => { user = await userFactory(testEnv, bibiBloxberg)
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { variables.userId = user.id
await expect( variables.pageSize = 25
query({ // bibi needs GDDs
query: listTransactionLinksAdmin, const bibisCreation = creations.find(
variables: { (creation) => creation.email === 'bibi@bloxberg.de',
...variables,
filters: {
withDeleted: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 7,
linkList: expect.arrayContaining([
expect.not.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
) )
}) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
}) await creationFactory(testEnv, bibisCreation!)
// bibis transaktion links
const bibisTransaktionLinks = transactionLinks.filter(
(transactionLink) => transactionLink.email === 'bibi@bloxberg.de',
)
for (let i = 0; i < bibisTransaktionLinks.length; i++) {
await transactionLinkFactory(testEnv, bibisTransaktionLinks[i])
}
describe('filter by expired', () => { // admin: only now log in
it('finds 5 open transaction links, 1 expired, and no redeemed', async () => { await mutate({
await expect( mutation: login,
query({ variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
query: listTransactionLinksAdmin, })
variables: {
...variables,
filters: {
withExpired: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 7,
linkList: expect.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.not.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
}) })
})
// TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory afterAll(async () => {
describe.skip('filter by redeemed', () => { await cleanDB()
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => { resetToken()
await expect( })
query({
query: listTransactionLinksAdmin, describe('without any filters', () => {
variables: { it('finds 6 open transaction links and no deleted or redeemed', async () => {
...variables, await expect(
filters: { query({
withDeleted: null, query: listTransactionLinksAdmin,
withExpired: null, variables,
withRedeemed: true, }),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.not.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
}, },
}, }),
}), )
).resolves.toEqual( })
expect.objectContaining({ })
data: {
listTransactionLinksAdmin: { describe('all filters are null', () => {
linkCount: 6, it('finds 6 open transaction links and no deleted or redeemed', async () => {
linkList: expect.arrayContaining([ await expect(
expect.not.objectContaining({ query({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(', query: listTransactionLinksAdmin,
createdAt: expect.any(String), variables: {
}), ...variables,
expect.objectContaining({ filters: {
memo: 'Yeah, eingelöst!', withDeleted: null,
redeemedAt: expect.any(String), withExpired: null,
redeemedBy: expect.any(Number), withRedeemed: null,
}), },
expect.not.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
}, },
}, }),
}), ).resolves.toEqual(
) expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.not.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
describe('filter with deleted', () => {
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withDeleted: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 7,
linkList: expect.arrayContaining([
expect.not.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
describe('filter by expired', () => {
it('finds 5 open transaction links, 1 expired, and no redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withExpired: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 7,
linkList: expect.arrayContaining([
expect.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.not.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
})
// TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory
describe.skip('filter by redeemed', () => {
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
await expect(
query({
query: listTransactionLinksAdmin,
variables: {
...variables,
filters: {
withDeleted: null,
withExpired: null,
withRedeemed: true,
},
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listTransactionLinksAdmin: {
linkCount: 6,
linkList: expect.arrayContaining([
expect.not.objectContaining({
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
createdAt: expect.any(String),
}),
expect.objectContaining({
memo: 'Yeah, eingelöst!',
redeemedAt: expect.any(String),
redeemedBy: expect.any(Number),
}),
expect.not.objectContaining({
memo: 'Da habe ich mich wohl etwas übernommen.',
deletedAt: expect.any(String),
}),
]),
},
},
}),
)
})
}) })
}) })
}) })
}) })
}) })
})
describe('transactionLinkCode', () => { describe('transactionLinkCode', () => {
const date = new Date() const date = new Date()
it('returns a string of length 24', () => { it('returns a string of length 24', () => {
expect(transactionLinkCode(date)).toHaveLength(24) expect(transactionLinkCode(date)).toHaveLength(24)
}) })
it('returns a string that ends with the hex value of date', () => { it('returns a string that ends with the hex value of date', () => {
const regexp = new RegExp(date.getTime().toString(16) + '$') const regexp = new RegExp(date.getTime().toString(16) + '$')
expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp)) expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp))
})
}) })
}) })

View File

@ -170,148 +170,154 @@ export class TransactionLinkResolver {
if (code.match(/^CL-/)) { if (code.match(/^CL-/)) {
// acquire lock // acquire lock
const releaseLock = await TRANSACTIONS_LOCK.acquire() const releaseLock = await TRANSACTIONS_LOCK.acquire()
logger.info('redeem contribution link...')
const now = new Date()
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ')
try { try {
const contributionLink = await queryRunner.manager logger.info('redeem contribution link...')
.createQueryBuilder() const now = new Date()
.select('contributionLink') const queryRunner = getConnection().createQueryRunner()
.from(DbContributionLink, 'contributionLink') await queryRunner.connect()
.where('contributionLink.code = :code', { code: code.replace('CL-', '') }) await queryRunner.startTransaction('REPEATABLE READ')
.getOne() try {
if (!contributionLink) { const contributionLink = await queryRunner.manager
logger.error('no contribution link found to given code:', code) .createQueryBuilder()
throw new Error('No contribution link found') .select('contributionLink')
} .from(DbContributionLink, 'contributionLink')
logger.info('...contribution link found with id', contributionLink.id) .where('contributionLink.code = :code', { code: code.replace('CL-', '') })
if (new Date(contributionLink.validFrom).getTime() > now.getTime()) { .getOne()
logger.error( if (!contributionLink) {
'contribution link is not valid yet. Valid from: ', logger.error('no contribution link found to given code:', code)
contributionLink.validFrom, throw new Error(`No contribution link found to given code: ${code}`)
)
throw new Error('Contribution link not valid yet')
}
if (contributionLink.validTo) {
if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) {
logger.error('contribution link is depricated. Valid to: ', contributionLink.validTo)
throw new Error('Contribution link is depricated')
} }
} logger.info('...contribution link found with id', contributionLink.id)
let alreadyRedeemed: DbContribution | undefined if (new Date(contributionLink.validFrom).getTime() > now.getTime()) {
switch (contributionLink.cycle) { logger.error(
case ContributionCycleType.ONCE: { 'contribution link is not valid yet. Valid from: ',
alreadyRedeemed = await queryRunner.manager contributionLink.validFrom,
.createQueryBuilder() )
.select('contribution') throw new Error('Contribution link not valid yet')
.from(DbContribution, 'contribution') }
.where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', { if (contributionLink.validTo) {
linkId: contributionLink.id, if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) {
id: user.id,
})
.getOne()
if (alreadyRedeemed) {
logger.error( logger.error(
'contribution link with rule ONCE already redeemed by user with id', 'contribution link is no longer valid. Valid to: ',
user.id, contributionLink.validTo,
) )
throw new Error('Contribution link already redeemed') throw new Error('Contribution link is no longer valid')
} }
break
} }
case ContributionCycleType.DAILY: { let alreadyRedeemed: DbContribution | undefined
const start = new Date() switch (contributionLink.cycle) {
start.setHours(0, 0, 0, 0) case ContributionCycleType.ONCE: {
const end = new Date() alreadyRedeemed = await queryRunner.manager
end.setHours(23, 59, 59, 999) .createQueryBuilder()
alreadyRedeemed = await queryRunner.manager .select('contribution')
.createQueryBuilder() .from(DbContribution, 'contribution')
.select('contribution') .where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', {
.from(DbContribution, 'contribution')
.where(
`contribution.contributionLinkId = :linkId AND contribution.userId = :id
AND Date(contribution.confirmedAt) BETWEEN :start AND :end`,
{
linkId: contributionLink.id, linkId: contributionLink.id,
id: user.id, id: user.id,
start, })
end, .getOne()
}, if (alreadyRedeemed) {
) logger.error(
.getOne() 'contribution link with rule ONCE already redeemed by user with id',
if (alreadyRedeemed) { user.id,
logger.error( )
'contribution link with rule DAILY already redeemed by user with id', throw new Error('Contribution link already redeemed')
user.id, }
) break
throw new Error('Contribution link already redeemed today') }
case ContributionCycleType.DAILY: {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date()
end.setHours(23, 59, 59, 999)
alreadyRedeemed = await queryRunner.manager
.createQueryBuilder()
.select('contribution')
.from(DbContribution, 'contribution')
.where(
`contribution.contributionLinkId = :linkId AND contribution.userId = :id
AND Date(contribution.confirmedAt) BETWEEN :start AND :end`,
{
linkId: contributionLink.id,
id: user.id,
start,
end,
},
)
.getOne()
if (alreadyRedeemed) {
logger.error(
'contribution link with rule DAILY already redeemed by user with id',
user.id,
)
throw new Error('Contribution link already redeemed today')
}
break
}
default: {
logger.error('contribution link has unknown cycle', contributionLink.cycle)
throw new Error('Contribution link has unknown cycle')
} }
break
} }
default: {
logger.error('contribution link has unknown cycle', contributionLink.cycle) const creations = await getUserCreation(user.id, clientTimezoneOffset)
throw new Error('Contribution link has unknown cycle') logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
const contribution = new DbContribution()
contribution.userId = user.id
contribution.createdAt = now
contribution.contributionDate = now
contribution.memo = contributionLink.memo
contribution.amount = contributionLink.amount
contribution.contributionLinkId = contributionLink.id
contribution.contributionType = ContributionType.LINK
contribution.contributionStatus = ContributionStatus.CONFIRMED
await queryRunner.manager.insert(DbContribution, contribution)
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: user.id })
.orderBy('transaction.id', 'DESC')
.getOne()
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now)
newBalance = decay.balance
} }
newBalance = newBalance.add(contributionLink.amount.toString())
const transaction = new DbTransaction()
transaction.typeId = TransactionTypeId.CREATION
transaction.memo = contribution.memo
transaction.userId = contribution.userId
transaction.previous = lastTransaction ? lastTransaction.id : null
transaction.amount = contribution.amount
transaction.creationDate = contribution.contributionDate
transaction.balance = newBalance
transaction.balanceDate = now
transaction.decay = decay ? decay.decay : new Decimal(0)
transaction.decayStart = decay ? decay.start : null
await queryRunner.manager.insert(DbTransaction, transaction)
contribution.confirmedAt = now
contribution.transactionId = transaction.id
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction()
logger.info('creation from contribution link commited successfuly.')
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Creation from contribution link was not successful: ${e}`)
throw new Error(`Creation from contribution link was not successful. ${e}`)
} finally {
await queryRunner.release()
} }
const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
const contribution = new DbContribution()
contribution.userId = user.id
contribution.createdAt = now
contribution.contributionDate = now
contribution.memo = contributionLink.memo
contribution.amount = contributionLink.amount
contribution.contributionLinkId = contributionLink.id
contribution.contributionType = ContributionType.LINK
contribution.contributionStatus = ContributionStatus.CONFIRMED
await queryRunner.manager.insert(DbContribution, contribution)
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: user.id })
.orderBy('transaction.id', 'DESC')
.getOne()
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now)
newBalance = decay.balance
}
newBalance = newBalance.add(contributionLink.amount.toString())
const transaction = new DbTransaction()
transaction.typeId = TransactionTypeId.CREATION
transaction.memo = contribution.memo
transaction.userId = contribution.userId
transaction.previous = lastTransaction ? lastTransaction.id : null
transaction.amount = contribution.amount
transaction.creationDate = contribution.contributionDate
transaction.balance = newBalance
transaction.balanceDate = now
transaction.decay = decay ? decay.decay : new Decimal(0)
transaction.decayStart = decay ? decay.start : null
await queryRunner.manager.insert(DbTransaction, transaction)
contribution.confirmedAt = now
contribution.transactionId = transaction.id
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction()
logger.info('creation from contribution link commited successfuly.')
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Creation from contribution link was not successful: ${e}`)
throw new Error(`Creation from contribution link was not successful. ${e}`)
} finally { } finally {
await queryRunner.release()
releaseLock() releaseLock()
} }
return true return true

View File

@ -45,29 +45,28 @@ export const executeTransaction = async (
recipient: dbUser, recipient: dbUser,
transactionLink?: dbTransactionLink | null, transactionLink?: dbTransactionLink | null,
): Promise<boolean> => { ): Promise<boolean> => {
logger.info(
`executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`,
)
if (sender.id === recipient.id) {
logger.error(`Sender and Recipient are the same.`)
throw new Error('Sender and Recipient are the same.')
}
if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
// acquire lock // acquire lock
const releaseLock = await TRANSACTIONS_LOCK.acquire() const releaseLock = await TRANSACTIONS_LOCK.acquire()
try { try {
logger.info(
`executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`,
)
if (sender.id === recipient.id) {
logger.error(`Sender and Recipient are the same.`)
throw new Error('Sender and Recipient are the same.')
}
if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
// validate amount // validate amount
const receivedCallDate = new Date() const receivedCallDate = new Date()
const sendBalance = await calculateBalance( const sendBalance = await calculateBalance(
@ -187,10 +186,10 @@ export const executeTransaction = async (
}) })
} }
logger.info(`finished executeTransaction successfully`) logger.info(`finished executeTransaction successfully`)
return true
} finally { } finally {
releaseLock() releaseLock()
} }
return true
} }
@Resolver() @Resolver()