Merge branch 'master' into test-login-user-resolver

This commit is contained in:
Ulf Gebhardt 2022-03-04 15:50:23 +01:00 committed by GitHub
commit 103f727798
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 1278 additions and 740 deletions

View File

@ -36,6 +36,7 @@
"graphql": "^15.6.1", "graphql": "^15.6.1",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "26.6.3", "jest": "26.6.3",
"portal-vue": "^2.1.7",
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"stats-webpack-plugin": "^0.7.0", "stats-webpack-plugin": "^0.7.0",
"vue": "^2.6.11", "vue": "^2.6.11",
@ -43,7 +44,6 @@
"vue-i18n": "^8.26.5", "vue-i18n": "^8.26.5",
"vue-jest": "^3.0.7", "vue-jest": "^3.0.7",
"vue-router": "^3.5.3", "vue-router": "^3.5.3",
"vue-toasted": "^1.1.28",
"vuex": "^3.6.2", "vuex": "^3.6.2",
"vuex-persistedstate": "^4.1.0" "vuex-persistedstate": "^4.1.0"
}, },

View File

@ -1,21 +1,17 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import ConfirmRegisterMailFormular from './ConfirmRegisterMailFormular.vue' import ConfirmRegisterMailFormular from './ConfirmRegisterMailFormular.vue'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
const apolloMutateMock = jest.fn().mockResolvedValue() const apolloMutateMock = jest.fn().mockResolvedValue()
const toastSuccessMock = jest.fn()
const toastErrorMock = jest.fn()
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$apollo: { $apollo: {
mutate: apolloMutateMock, mutate: apolloMutateMock,
}, },
$toasted: {
success: toastSuccessMock,
error: toastErrorMock,
},
} }
const propsData = { const propsData = {
@ -54,7 +50,7 @@ describe('ConfirmRegisterMailFormular', () => {
}) })
it('toasts a success message', () => { it('toasts a success message', () => {
expect(toastSuccessMock).toBeCalledWith('unregister_mail.success') expect(toastSuccessSpy).toBeCalledWith('unregister_mail.success')
}) })
}) })
@ -66,7 +62,7 @@ describe('ConfirmRegisterMailFormular', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('unregister_mail.error') expect(toastErrorSpy).toBeCalledWith('unregister_mail.error')
}) })
}) })
}) })

View File

@ -48,10 +48,10 @@ export default {
}, },
}) })
.then(() => { .then(() => {
this.$toasted.success(this.$t('unregister_mail.success', { email: this.email })) this.toastSuccess(this.$t('unregister_mail.success', { email: this.email }))
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(this.$t('unregister_mail.error', { message: error.message })) this.toastError(this.$t('unregister_mail.error', { message: error.message }))
}) })
}, },
}, },

View File

@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import CreationFormular from './CreationFormular.vue' import CreationFormular from './CreationFormular.vue'
import { createPendingCreation } from '../graphql/createPendingCreation' import { createPendingCreation } from '../graphql/createPendingCreation'
import { createPendingCreations } from '../graphql/createPendingCreations' import { createPendingCreations } from '../graphql/createPendingCreations'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
@ -11,8 +12,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({
}, },
}) })
const stateCommitMock = jest.fn() const stateCommitMock = jest.fn()
const toastedErrorMock = jest.fn()
const toastedSuccessMock = jest.fn()
const mocks = { const mocks = {
$t: jest.fn((t, options) => (options ? [t, options] : t)), $t: jest.fn((t, options) => (options ? [t, options] : t)),
@ -32,10 +31,6 @@ const mocks = {
}, },
}, },
}, },
$toasted: {
error: toastedErrorMock,
success: toastedSuccessMock,
},
} }
const propsData = { const propsData = {
@ -140,7 +135,7 @@ describe('CreationFormular', () => {
}) })
it('toasts a success message', () => { it('toasts a success message', () => {
expect(toastedSuccessMock).toBeCalledWith([ expect(toastSuccessSpy).toBeCalledWith([
'creation_form.toasted', 'creation_form.toasted',
{ email: 'benjamin@bluemchen.de', value: '90' }, { email: 'benjamin@bluemchen.de', value: '90' },
]) ])
@ -162,7 +157,7 @@ describe('CreationFormular', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Ouch!') expect(toastErrorSpy).toBeCalledWith('Ouch!')
}) })
}) })
@ -292,7 +287,7 @@ describe('CreationFormular', () => {
}) })
it('toast success message', () => { it('toast success message', () => {
expect(toastedSuccessMock).toBeCalled() expect(toastSuccessSpy).toBeCalled()
}) })
it('store commit openCreationPlus', () => { it('store commit openCreationPlus', () => {
@ -426,13 +421,14 @@ describe('CreationFormular', () => {
expect(stateCommitMock).toBeCalledWith('openCreationsPlus', 0) expect(stateCommitMock).toBeCalledWith('openCreationsPlus', 0)
}) })
it('toasts two errors', () => { it('emits remove all bookmarks', () => {
expect(toastedErrorMock).toBeCalledWith( expect(wrapper.emitted('remove-all-bookmark')).toBeTruthy()
'Could not created PendingCreation for bob@baumeister.de', })
)
expect(toastedErrorMock).toBeCalledWith( it('emits toast failed creations with two emails', () => {
'Could not created PendingCreation for bibi@bloxberg.de', expect(wrapper.emitted('toast-failed-creations')).toEqual([
) [['bob@baumeister.de', 'bibi@bloxberg.de']],
])
}) })
}) })
@ -454,7 +450,7 @@ describe('CreationFormular', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Oh no!') expect(toastErrorSpy).toBeCalledWith('Oh no!')
}) })
}) })
}) })

View File

@ -166,20 +166,21 @@ export default {
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
}) })
.then((result) => { .then((result) => {
const failedCreations = []
this.$store.commit( this.$store.commit(
'openCreationsPlus', 'openCreationsPlus',
result.data.createPendingCreations.successfulCreation.length, result.data.createPendingCreations.successfulCreation.length,
) )
if (result.data.createPendingCreations.failedCreation.length > 0) { if (result.data.createPendingCreations.failedCreation.length > 0) {
result.data.createPendingCreations.failedCreation.forEach((failed) => { result.data.createPendingCreations.failedCreation.forEach((email) => {
// TODO: Please localize this error message failedCreations.push(email)
this.$toasted.error('Could not created PendingCreation for ' + failed)
}) })
} }
this.$emit('remove-all-bookmark') this.$emit('remove-all-bookmark')
this.$emit('toast-failed-creations', failedCreations)
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
} else if (this.type === 'singleCreation') { } else if (this.type === 'singleCreation') {
submitObj = { submitObj = {
@ -196,19 +197,19 @@ export default {
}) })
.then((result) => { .then((result) => {
this.$emit('update-user-data', this.item, result.data.createPendingCreation) this.$emit('update-user-data', this.item, result.data.createPendingCreation)
this.$toasted.success( this.$store.commit('openCreationsPlus', 1)
this.toastSuccess(
this.$t('creation_form.toasted', { this.$t('creation_form.toasted', {
value: this.value, value: this.value,
email: this.item.email, email: this.item.email,
}), }),
) )
this.$store.commit('openCreationsPlus', 1)
// what is this? Tests says that this.text is not reseted // what is this? Tests says that this.text is not reseted
this.$refs.creationForm.reset() this.$refs.creationForm.reset()
this.value = 0 this.value = 0
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
this.$refs.creationForm.reset() this.$refs.creationForm.reset()
this.value = 0 this.value = 0
}) })

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import CreationTransactionListFormular from './CreationTransactionListFormular.vue' import CreationTransactionListFormular from './CreationTransactionListFormular.vue'
import { toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
@ -50,17 +51,12 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
}, },
}) })
const toastedErrorMock = jest.fn()
const mocks = { const mocks = {
$d: jest.fn((t) => t), $d: jest.fn((t) => t),
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$apollo: { $apollo: {
query: apolloQueryMock, query: apolloQueryMock,
}, },
$toasted: {
error: toastedErrorMock,
},
} }
const propsData = { const propsData = {
@ -109,7 +105,7 @@ describe('CreationTransactionListFormular', () => {
}) })
it('toast error', () => { it('toast error', () => {
expect(toastedErrorMock).toBeCalledWith('OUCH!') expect(toastErrorSpy).toBeCalledWith('OUCH!')
}) })
}) })
}) })

View File

@ -62,7 +62,7 @@ export default {
this.items = result.data.transactionList.transactions.filter((t) => t.type === 'creation') this.items = result.data.transactionList.transactions.filter((t) => t.type === 'creation')
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
}, },

View File

@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import DeletedUserFormular from './DeletedUserFormular.vue' import DeletedUserFormular from './DeletedUserFormular.vue'
import { deleteUser } from '../graphql/deleteUser' import { deleteUser } from '../graphql/deleteUser'
import { unDeleteUser } from '../graphql/unDeleteUser' import { unDeleteUser } from '../graphql/unDeleteUser'
import { toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
@ -13,9 +14,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({
}, },
}) })
const toastedErrorMock = jest.fn()
const toastedSuccessMock = jest.fn()
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$apollo: { $apollo: {
@ -29,10 +27,6 @@ const mocks = {
}, },
}, },
}, },
$toasted: {
error: toastedErrorMock,
success: toastedSuccessMock,
},
} }
const propsData = { const propsData = {
@ -118,10 +112,6 @@ describe('DeletedUserFormular', () => {
) )
}) })
it('toasts a success message', () => {
expect(toastedSuccessMock).toBeCalledWith('user_deleted')
})
it('emits update deleted At', () => { it('emits update deleted At', () => {
expect(wrapper.emitted('updateDeletedAt')).toEqual( expect(wrapper.emitted('updateDeletedAt')).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@ -147,7 +137,7 @@ describe('DeletedUserFormular', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Oh no!') expect(toastErrorSpy).toBeCalledWith('Oh no!')
}) })
}) })
@ -215,10 +205,6 @@ describe('DeletedUserFormular', () => {
) )
}) })
it('toasts a success message', () => {
expect(toastedSuccessMock).toBeCalledWith('user_recovered')
})
it('emits update deleted At', () => { it('emits update deleted At', () => {
expect(wrapper.emitted('updateDeletedAt')).toEqual( expect(wrapper.emitted('updateDeletedAt')).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@ -244,7 +230,7 @@ describe('DeletedUserFormular', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Oh no!') expect(toastErrorSpy).toBeCalledWith('Oh no!')
}) })
}) })

View File

@ -45,7 +45,6 @@ export default {
}, },
}) })
.then((result) => { .then((result) => {
this.$toasted.success(this.$t('user_deleted'))
this.$emit('updateDeletedAt', { this.$emit('updateDeletedAt', {
userId: this.item.userId, userId: this.item.userId,
deletedAt: result.data.deleteUser, deletedAt: result.data.deleteUser,
@ -53,7 +52,7 @@ export default {
this.checked = false this.checked = false
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
unDeleteUser() { unDeleteUser() {
@ -65,7 +64,7 @@ export default {
}, },
}) })
.then((result) => { .then((result) => {
this.$toasted.success(this.$t('user_recovered')) this.toastSuccess(this.$t('user_recovered'))
this.$emit('updateDeletedAt', { this.$emit('updateDeletedAt', {
userId: this.item.userId, userId: this.item.userId,
deletedAt: result.data.unDeleteUser, deletedAt: result.data.unDeleteUser,
@ -73,7 +72,7 @@ export default {
this.checked = false this.checked = false
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
}, },

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import EditCreationFormular from './EditCreationFormular.vue' import EditCreationFormular from './EditCreationFormular.vue'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
@ -16,8 +17,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({
}) })
const stateCommitMock = jest.fn() const stateCommitMock = jest.fn()
const toastedErrorMock = jest.fn()
const toastedSuccessMock = jest.fn()
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
@ -37,10 +36,6 @@ const mocks = {
}, },
commit: stateCommitMock, commit: stateCommitMock,
}, },
$toasted: {
error: toastedErrorMock,
success: toastedSuccessMock,
},
} }
const now = new Date(Date.now()) const now = new Date(Date.now())
@ -142,7 +137,7 @@ describe('EditCreationFormular', () => {
}) })
it('toasts a success message', () => { it('toasts a success message', () => {
expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_update') expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_update')
}) })
}) })
@ -155,7 +150,7 @@ describe('EditCreationFormular', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Oh no!') expect(toastErrorSpy).toBeCalledWith('Oh no!')
}) })
}) })
}) })

View File

@ -132,7 +132,7 @@ export default {
moderator: Number(result.data.updatePendingCreation.moderator), moderator: Number(result.data.updatePendingCreation.moderator),
row: this.row, row: this.row,
}) })
this.$toasted.success( this.toastSuccess(
this.$t('creation_form.toasted_update', { this.$t('creation_form.toasted_update', {
value: this.value, value: this.value,
email: this.item.email, email: this.item.email,
@ -144,7 +144,7 @@ export default {
this.value = 0 this.value = 0
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
// das creation Formular reseten // das creation Formular reseten
this.$refs.updateCreationForm.reset() this.$refs.updateCreationForm.reset()
// Den geschöpften Wert auf o setzen // Den geschöpften Wert auf o setzen

View File

@ -73,10 +73,6 @@ const mocks = {
}, },
}, },
}, },
$toasted: {
error: jest.fn(),
success: jest.fn(),
},
} }
describe('SearchUserTable', () => { describe('SearchUserTable', () => {

View File

@ -5,6 +5,7 @@
"confirmed": "bestätigt", "confirmed": "bestätigt",
"creation": "Schöpfung", "creation": "Schöpfung",
"creation_form": { "creation_form": {
"creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.",
"creation_for": "Aktives Grundeinkommen für", "creation_for": "Aktives Grundeinkommen für",
"enter_text": "Text eintragen", "enter_text": "Text eintragen",
"form": "Schöpfungsformular", "form": "Schöpfungsformular",
@ -28,6 +29,7 @@
"delete_user": "Nutzer löschen", "delete_user": "Nutzer löschen",
"details": "Details", "details": "Details",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"error": "Fehler",
"e_mail": "E-Mail", "e_mail": "E-Mail",
"firstname": "Vorname", "firstname": "Vorname",
"gradido_admin_footer": "Gradido Akademie Adminkonsole", "gradido_admin_footer": "Gradido Akademie Adminkonsole",
@ -68,6 +70,7 @@
"remove_all": "alle Nutzer entfernen", "remove_all": "alle Nutzer entfernen",
"save": "Speichern", "save": "Speichern",
"status": "Status", "status": "Status",
"success": "Erfolg",
"text": "Text", "text": "Text",
"transaction": "Transaktion", "transaction": "Transaktion",
"transactionlist": { "transactionlist": {

View File

@ -5,6 +5,7 @@
"confirmed": "confirmed", "confirmed": "confirmed",
"creation": "Creation", "creation": "Creation",
"creation_form": { "creation_form": {
"creation_failed": "Could not create pending creation for {email}",
"creation_for": "Active Basic Income for", "creation_for": "Active Basic Income for",
"enter_text": "Enter text", "enter_text": "Enter text",
"form": "Creation form", "form": "Creation form",
@ -28,6 +29,7 @@
"delete_user": "Delete user", "delete_user": "Delete user",
"details": "Details", "details": "Details",
"edit": "Edit", "edit": "Edit",
"error": "Error",
"e_mail": "E-mail", "e_mail": "E-mail",
"firstname": "Firstname", "firstname": "Firstname",
"gradido_admin_footer": "Gradido Academy Admin Console", "gradido_admin_footer": "Gradido Academy Admin Console",
@ -68,6 +70,7 @@
"remove_all": "Remove all users", "remove_all": "Remove all users",
"save": "Speichern", "save": "Speichern",
"status": "Status", "status": "Status",
"success": "Success",
"text": "Text", "text": "Text",
"transaction": "Transaction", "transaction": "Transaction",
"transactionlist": { "transactionlist": {

View File

@ -13,31 +13,24 @@ import i18n from './i18n'
import VueApollo from 'vue-apollo' import VueApollo from 'vue-apollo'
import PortalVue from 'portal-vue'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css' import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css' import 'bootstrap-vue/dist/bootstrap-vue.css'
import Toasted from 'vue-toasted' import { toasters } from './mixins/toaster'
import { apolloProvider } from './plugins/apolloProvider' import { apolloProvider } from './plugins/apolloProvider'
Vue.use(PortalVue)
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
Vue.use(IconsPlugin) Vue.use(IconsPlugin)
Vue.use(VueApollo) Vue.use(VueApollo)
Vue.use(Toasted, { Vue.mixin(toasters)
position: 'top-center',
duration: 5000,
fullWidth: true,
action: {
text: 'x',
onClick: (e, toastObject) => {
toastObject.goAway(0)
},
},
})
addNavigationGuards(router, store, apolloProvider.defaultClient, i18n) addNavigationGuards(router, store, apolloProvider.defaultClient, i18n)

View File

@ -0,0 +1,30 @@
export const toasters = {
methods: {
toastSuccess(message) {
this.toast(message, {
title: this.$t('success'),
variant: 'success',
})
},
toastError(message) {
this.toast(message, {
title: this.$t('error'),
variant: 'danger',
})
},
toast(message, options) {
// for unit tests, check that replace is present
if (message.replace) message = message.replace(/^GraphQL error: /, '')
this.$bvToast.toast(message, {
autoHideDelay: 5000,
appendToast: true,
solid: true,
toaster: 'b-toaster-top-right',
headerClass: 'gdd-toaster-title',
bodyClass: 'gdd-toaster-body',
toastClass: 'gdd-toaster',
...options,
})
},
},
}

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import Creation from './Creation.vue' import Creation from './Creation.vue'
import { toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
@ -29,18 +30,14 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
}, },
}) })
const toastErrorMock = jest.fn()
const storeCommitMock = jest.fn() const storeCommitMock = jest.fn()
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t, options) => (options ? [t, options] : t)),
$d: jest.fn((d) => d), $d: jest.fn((d) => d),
$apollo: { $apollo: {
query: apolloQueryMock, query: apolloQueryMock,
}, },
$toasted: {
error: toastErrorMock,
},
$store: { $store: {
commit: storeCommitMock, commit: storeCommitMock,
state: { state: {
@ -236,6 +233,25 @@ describe('Creation', () => {
}) })
}) })
describe('failed creations', () => {
beforeEach(async () => {
await wrapper
.findComponent({ name: 'CreationFormular' })
.vm.$emit('toast-failed-creations', ['bibi@bloxberg.de', 'benjamin@bluemchen.de'])
})
it('toasts two error messages', () => {
expect(toastErrorSpy).toBeCalledWith([
'creation_form.creation_failed',
{ email: 'bibi@bloxberg.de' },
])
expect(toastErrorSpy).toBeCalledWith([
'creation_form.creation_failed',
{ email: 'benjamin@bluemchen.de' },
])
})
})
describe('watchers', () => { describe('watchers', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
@ -298,7 +314,7 @@ describe('Creation', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Ouch') expect(toastErrorSpy).toBeCalledWith('Ouch')
}) })
}) })
}) })

View File

@ -56,6 +56,7 @@
:creation="creation" :creation="creation"
:items="itemsMassCreation" :items="itemsMassCreation"
@remove-all-bookmark="removeAllBookmarks" @remove-all-bookmark="removeAllBookmarks"
@toast-failed-creations="toastFailedCreations"
/> />
</b-col> </b-col>
</b-row> </b-row>
@ -118,7 +119,7 @@ export default {
} }
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
pushItem(selectedItem) { pushItem(selectedItem) {
@ -144,6 +145,11 @@ export default {
this.$store.commit('setUserSelectedInMassCreation', []) this.$store.commit('setUserSelectedInMassCreation', [])
this.getUsers() this.getUsers()
}, },
toastFailedCreations(failedCreations) {
failedCreations.forEach((email) =>
this.toastError(this.$t('creation_form.creation_failed', { email })),
)
},
}, },
computed: { computed: {
Searchfields() { Searchfields() {

View File

@ -2,12 +2,11 @@ import { mount } from '@vue/test-utils'
import CreationConfirm from './CreationConfirm.vue' import CreationConfirm from './CreationConfirm.vue'
import { deletePendingCreation } from '../graphql/deletePendingCreation' import { deletePendingCreation } from '../graphql/deletePendingCreation'
import { confirmPendingCreation } from '../graphql/confirmPendingCreation' import { confirmPendingCreation } from '../graphql/confirmPendingCreation'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
const storeCommitMock = jest.fn() const storeCommitMock = jest.fn()
const toastedErrorMock = jest.fn()
const toastedSuccessMock = jest.fn()
const apolloQueryMock = jest.fn().mockResolvedValue({ const apolloQueryMock = jest.fn().mockResolvedValue({
data: { data: {
getPendingCreations: [ getPendingCreations: [
@ -47,10 +46,6 @@ const mocks = {
query: apolloQueryMock, query: apolloQueryMock,
mutate: apolloMutateMock, mutate: apolloMutateMock,
}, },
$toasted: {
error: toastedErrorMock,
success: toastedSuccessMock,
},
} }
describe('CreationConfirm', () => { describe('CreationConfirm', () => {
@ -101,7 +96,7 @@ describe('CreationConfirm', () => {
}) })
it('toasts a success message', () => { it('toasts a success message', () => {
expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_delete') expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
}) })
}) })
@ -112,7 +107,7 @@ describe('CreationConfirm', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Ouchhh!') expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
}) })
}) })
@ -158,7 +153,7 @@ describe('CreationConfirm', () => {
}) })
it('toasts a success message', () => { it('toasts a success message', () => {
expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_created') expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_created')
}) })
it('has 1 item left in the table', () => { it('has 1 item left in the table', () => {
@ -173,7 +168,7 @@ describe('CreationConfirm', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Ouchhh!') expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
}) })
}) })
}) })
@ -189,7 +184,7 @@ describe('CreationConfirm', () => {
}) })
it('toast an error message', () => { it('toast an error message', () => {
expect(toastedErrorMock).toBeCalledWith('Ouch!') expect(toastErrorSpy).toBeCalledWith('Ouch!')
}) })
}) })
}) })

View File

@ -43,10 +43,10 @@ export default {
}) })
.then((result) => { .then((result) => {
this.updatePendingCreations(item.id) this.updatePendingCreations(item.id)
this.$toasted.success(this.$t('creation_form.toasted_delete')) this.toastSuccess(this.$t('creation_form.toasted_delete'))
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
confirmCreation() { confirmCreation() {
@ -60,11 +60,11 @@ export default {
.then((result) => { .then((result) => {
this.overlay = false this.overlay = false
this.updatePendingCreations(this.item.id) this.updatePendingCreations(this.item.id)
this.$toasted.success(this.$t('creation_form.toasted_created')) this.toastSuccess(this.$t('creation_form.toasted_created'))
}) })
.catch((error) => { .catch((error) => {
this.overlay = false this.overlay = false
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
getPendingCreations() { getPendingCreations() {
@ -79,7 +79,7 @@ export default {
this.$store.commit('setOpenCreations', result.data.getPendingCreations.length) this.$store.commit('setOpenCreations', result.data.getPendingCreations.length)
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
updatePendingCreations(id) { updatePendingCreations(id) {

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import UserSearch from './UserSearch.vue' import UserSearch from './UserSearch.vue'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue const localVue = global.localVue
@ -15,6 +16,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
creation: [200, 400, 600], creation: [200, 400, 600],
emailChecked: true, emailChecked: true,
deletedAt: null,
}, },
{ {
userId: 2, userId: 2,
@ -23,6 +25,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
email: 'benjamin@bluemchen.de', email: 'benjamin@bluemchen.de',
creation: [1000, 1000, 1000], creation: [1000, 1000, 1000],
emailChecked: true, emailChecked: true,
deletedAt: null,
}, },
{ {
userId: 3, userId: 3,
@ -31,6 +34,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
email: 'peter@lustig.de', email: 'peter@lustig.de',
creation: [0, 0, 0], creation: [0, 0, 0],
emailChecked: true, emailChecked: true,
deletedAt: null,
}, },
{ {
userId: 4, userId: 4,
@ -39,23 +43,19 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
email: 'new@user.ch', email: 'new@user.ch',
creation: [1000, 1000, 1000], creation: [1000, 1000, 1000],
emailChecked: false, emailChecked: false,
deletedAt: null,
}, },
], ],
}, },
}, },
}) })
const toastErrorMock = jest.fn()
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => String(d)), $d: jest.fn((d) => String(d)),
$apollo: { $apollo: {
query: apolloQueryMock, query: apolloQueryMock,
}, },
$toasted: {
error: toastErrorMock,
},
} }
describe('UserSearch', () => { describe('UserSearch', () => {
@ -187,6 +187,21 @@ describe('UserSearch', () => {
}) })
}) })
describe('delete user', () => {
const now = new Date()
beforeEach(async () => {
wrapper.findComponent({ name: 'SearchUserTable' }).vm.$emit('updateDeletedAt', 4, now)
})
it('marks the user as deleted', () => {
expect(wrapper.vm.searchResult.find((obj) => obj.userId === 4).deletedAt).toEqual(now)
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('user_deleted')
})
})
describe('apollo returns error', () => { describe('apollo returns error', () => {
beforeEach(() => { beforeEach(() => {
apolloQueryMock.mockRejectedValue({ apolloQueryMock.mockRejectedValue({
@ -196,7 +211,7 @@ describe('UserSearch', () => {
}) })
it('toasts an error message', () => { it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Ouch') expect(toastErrorSpy).toBeCalledWith('Ouch')
}) })
}) })
}) })

View File

@ -94,11 +94,12 @@ export default {
this.searchResult = result.data.searchUsers.userList this.searchResult = result.data.searchUsers.userList
}) })
.catch((error) => { .catch((error) => {
this.$toasted.error(error.message) this.toastError(error.message)
}) })
}, },
updateDeletedAt(userId, deletedAt) { updateDeletedAt(userId, deletedAt) {
this.searchResult.find((obj) => obj.userId === userId).deletedAt = deletedAt this.searchResult.find((obj) => obj.userId === userId).deletedAt = deletedAt
this.toastSuccess(this.$t('user_deleted'))
}, },
}, },
watch: { watch: {

View File

@ -5,11 +5,18 @@ import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
// without this async calls are not working // without this async calls are not working
import 'regenerator-runtime' import 'regenerator-runtime'
import { toasters } from '../src/mixins/toaster'
export const toastErrorSpy = jest.spyOn(toasters.methods, 'toastError')
export const toastSuccessSpy = jest.spyOn(toasters.methods, 'toastSuccess')
global.localVue = createLocalVue() global.localVue = createLocalVue()
global.localVue.use(BootstrapVue) global.localVue.use(BootstrapVue)
global.localVue.use(IconsPlugin) global.localVue.use(IconsPlugin)
global.localVue.mixin(toasters)
// throw errors for vue warnings to force the programmers to take care about warnings // throw errors for vue warnings to force the programmers to take care about warnings
Vue.config.warnHandler = (w) => { Vue.config.warnHandler = (w) => {
throw new Error(w) throw new Error(w)

View File

@ -12512,11 +12512,6 @@ vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0:
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue-toasted@^1.1.28:
version "1.1.28"
resolved "https://registry.yarnpkg.com/vue-toasted/-/vue-toasted-1.1.28.tgz#dbabb83acc89f7a9e8765815e491d79f0dc65c26"
integrity sha512-UUzr5LX51UbbiROSGZ49GOgSzFxaMHK6L00JV8fir/CYNJCpIIvNZ5YmS4Qc8Y2+Z/4VVYRpeQL2UO0G800Raw==
vue@^2.6.11: vue@^2.6.11:
version "2.6.14" version "2.6.14"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"

View File

@ -24,6 +24,7 @@
"axios": "^0.21.1", "axios": "^0.21.1",
"class-validator": "^0.13.1", "class-validator": "^0.13.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"decimal.js-light": "^2.5.1",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"express": "^4.17.1", "express": "^4.17.1",
"graphql": "^15.5.1", "graphql": "^15.5.1",

View File

@ -1,10 +1,16 @@
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env) // ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
import dotenv from 'dotenv' import dotenv from 'dotenv'
import Decimal from 'decimal.js-light'
dotenv.config() dotenv.config()
Decimal.set({
precision: 25,
rounding: Decimal.ROUND_HALF_UP,
})
const constants = { const constants = {
DB_VERSION: '0027-clean_transaction_table', DB_VERSION: '0029-clean_transaction_table',
DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0 DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0
} }

View File

@ -1,12 +1,13 @@
import { ArgsType, Field } from 'type-graphql' import { ArgsType, Field } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ArgsType() @ArgsType()
export default class TransactionSendArgs { export default class TransactionSendArgs {
@Field(() => String) @Field(() => String)
email: string email: string
@Field(() => Number) @Field(() => Decimal)
amount: number amount: Decimal
@Field(() => String) @Field(() => String)
memo: string memo: string

View File

@ -4,6 +4,8 @@ export enum TransactionTypeId {
CREATION = 1, CREATION = 1,
SEND = 2, SEND = 2,
RECEIVE = 3, RECEIVE = 3,
// This is a virtual property, never occurring on the database
DECAY = 4,
} }
registerEnumType(TransactionTypeId, { registerEnumType(TransactionTypeId, {

View File

@ -1,21 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field } from 'type-graphql' import { ObjectType, Field } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ObjectType() @ObjectType()
export class Balance { export class Balance {
constructor(json: any) { constructor(json: any) {
this.balance = Number(json.balance) this.balance = json.balance
this.decay = Number(json.decay) this.decay = json.decay
this.decayDate = json.decay_date this.decayDate = json.decay_date
} }
@Field(() => Number) @Field(() => Decimal)
balance: number balance: Decimal
@Field(() => Number) @Field(() => Decimal)
decay: number decay: Decimal
@Field(() => String) @Field(() => Date)
decayDate: string decayDate: Date
} }

View File

@ -1,33 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field, Int } from 'type-graphql' import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ObjectType() @ObjectType()
export class Decay { export class Decay {
constructor(json?: any) { constructor(
if (json) { balance: Decimal,
this.balance = Number(json.balance) decay: Decimal,
this.decayStart = json.decay_start start: Date | null,
this.decayEnd = json.decay_end end: Date | null,
this.decayDuration = json.decay_duration duration: number | null,
this.decayStartBlock = json.decay_start_block ) {
} this.balance = balance
this.decay = decay
this.start = start
this.end = end
this.duration = duration
} }
@Field(() => Number) @Field(() => Decimal)
balance: number balance: Decimal
// timestamp in seconds @Field(() => Decimal)
@Field(() => Int, { nullable: true }) decay: Decimal
decayStart: string
// timestamp in seconds @Field(() => Date, { nullable: true })
@Field(() => Int, { nullable: true }) start: Date | null
decayEnd: string
@Field(() => String, { nullable: true }) @Field(() => Date, { nullable: true })
decayDuration?: number end: Date | null
@Field(() => Int, { nullable: true }) @Field(() => Int, { nullable: true })
decayStartBlock?: string duration: number | null
} }

View File

@ -1,59 +1,70 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field } from 'type-graphql' import { ObjectType, Field } from 'type-graphql'
import { Decay } from './Decay' import { Decay } from './Decay'
import { Transaction as dbTransaction } from '@entity/Transaction'
// we need a better solution for the decay block: import Decimal from 'decimal.js-light'
// the first transaction on the first page shows the decay since the last transaction import { TransactionTypeId } from '../enum/TransactionTypeId'
// the format is actually a Decay and not a Transaction. import { User } from './User'
// Therefore we have a lot of nullable fields, which should be always present
@ObjectType() @ObjectType()
export class Transaction { export class Transaction {
constructor() { constructor(transaction: dbTransaction, user: User, linkedUser: User | null = null) {
this.type = '' this.id = transaction.id
this.balance = 0 this.user = user
this.totalBalance = 0 this.previous = transaction.previous
this.memo = '' this.typeId = transaction.typeId
this.firstTransaction = false this.amount = transaction.amount
this.balance = transaction.balance
this.balanceDate = transaction.balanceDate
if (!transaction.decayStart) {
this.decay = new Decay(transaction.balance, new Decimal(0), null, null, null)
} else {
this.decay = new Decay(
transaction.balance,
transaction.decay,
transaction.decayStart,
transaction.balanceDate,
Math.round((transaction.balanceDate.getTime() - transaction.decayStart.getTime()) / 1000),
)
}
this.memo = transaction.memo
this.creationDate = transaction.creationDate
this.linkedUser = linkedUser
this.linkedTransactionId = transaction.linkedTransactionId
} }
@Field(() => String)
type: string
@Field(() => Number) @Field(() => Number)
balance: number id: number
@Field(() => Number) @Field(() => User)
totalBalance: number user: User
@Field({ nullable: true }) @Field(() => Number, { nullable: true })
decayStart?: string previous: number | null
@Field({ nullable: true }) @Field(() => TransactionTypeId)
decayEnd?: string typeId: TransactionTypeId
@Field({ nullable: true }) @Field(() => Decimal)
decayDuration?: number amount: Decimal
@Field(() => Decimal)
balance: Decimal
@Field(() => Date)
balanceDate: Date
@Field(() => Decay)
decay: Decay
@Field(() => String) @Field(() => String)
memo: string memo: string
@Field(() => Date, { nullable: true })
creationDate: Date | null
@Field(() => User, { nullable: true })
linkedUser: User | null
@Field(() => Number, { nullable: true }) @Field(() => Number, { nullable: true })
transactionId?: number linkedTransactionId?: number | null
@Field({ nullable: true })
name?: string
@Field({ nullable: true })
email?: string
@Field({ nullable: true })
date?: string
@Field({ nullable: true })
decay?: Decay
@Field(() => Boolean)
firstTransaction: boolean
} }

View File

@ -1,32 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field } from 'type-graphql' import { ObjectType, Field } from 'type-graphql'
import CONFIG from '../../config'
import Decimal from 'decimal.js-light'
import { Transaction } from './Transaction' import { Transaction } from './Transaction'
@ObjectType() @ObjectType()
export class TransactionList { export class TransactionList {
constructor() { constructor(
this.gdtSum = 0 balance: Decimal,
this.count = 0 transactions: Transaction[],
this.balance = 0 count: number,
this.decay = 0 balanceGDT?: number | null,
this.decayDate = '' decayStartBlock: Date = CONFIG.DECAY_START_TIME,
) {
this.balance = balance
this.transactions = transactions
this.count = count
this.balanceGDT = balanceGDT || null
this.decayStartBlock = decayStartBlock
} }
@Field(() => Number, { nullable: true }) @Field(() => Number, { nullable: true })
gdtSum: number | null balanceGDT: number | null
@Field(() => Number) @Field(() => Number)
count: number count: number
@Field(() => Number) @Field(() => Number)
balance: number balance: Decimal
@Field(() => Number) @Field(() => Date)
decay: number decayStartBlock: Date
@Field(() => String)
decayDate: string
@Field(() => [Transaction]) @Field(() => [Transaction])
transactions: Transaction[] transactions: Transaction[]

View File

@ -1,75 +1,74 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ import { ObjectType, Field } from 'type-graphql'
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field, Int } from 'type-graphql'
import { KlickTipp } from './KlickTipp' import { KlickTipp } from './KlickTipp'
import { User as dbUser } from '@entity/User'
@ObjectType() @ObjectType()
export class User { export class User {
/* constructor(user: dbUser) {
@Field(() => ID) this.id = user.id
@PrimaryGeneratedColumn() this.email = user.email
id: number this.firstName = user.firstName
*/ this.lastName = user.lastName
constructor(json?: any) { this.deletedAt = user.deletedAt
if (json) { this.createdAt = user.createdAt
this.id = json.id this.emailChecked = user.emailChecked
this.email = json.email this.language = user.language
this.firstName = json.first_name this.publisherId = user.publisherId
this.lastName = json.last_name // TODO
this.pubkey = json.public_hex this.isAdmin = null
this.language = json.language this.coinanimation = null
this.publisherId = json.publisher_id this.klickTipp = null
this.isAdmin = json.isAdmin this.hasElopage = null
}
} }
@Field(() => Number) @Field(() => Number)
id: number id: number
// `public_key` binary(32) DEFAULT NULL,
// `privkey` binary(80) DEFAULT NULL,
// TODO privacy issue here
@Field(() => String) @Field(() => String)
email: string email: string
@Field(() => String) @Field(() => String, { nullable: true })
firstName: string firstName: string | null
@Field(() => String) @Field(() => String, { nullable: true })
lastName: string lastName: string | null
@Field(() => String) @Field(() => Date, { nullable: true })
pubkey: string deletedAt: Date | null
/*
@Field(() => String)
pubkey: string
// not sure about the type here. Maybe better to have a string // `password` bigint(20) unsigned DEFAULT 0,
@Field(() => number) // `email_hash` binary(32) DEFAULT NULL,
created: number
@Field(() =>>> Boolean) @Field(() => Date)
createdAt: Date
@Field(() => Boolean)
emailChecked: boolean emailChecked: boolean
*/
@Field(() => String) @Field(() => String)
language: string language: string
/* // This is not the users publisherId, but the one of the users who recommend him
@Field(() => Boolean) @Field(() => Number, { nullable: true })
disabled: boolean publisherId: number | null
*/
// what is publisherId? // `passphrase` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
@Field(() => Int, { nullable: true })
publisherId?: number
@Field(() => Boolean) // TODO this is a bit inconsistent with what we query from the database
isAdmin: boolean // therefore all those fields are now nullable with default value null
@Field(() => Boolean, { nullable: true })
@Field(() => Boolean) isAdmin: boolean | null
coinanimation: boolean
@Field(() => KlickTipp)
klickTipp: KlickTipp
@Field(() => Boolean, { nullable: true }) @Field(() => Boolean, { nullable: true })
hasElopage?: boolean coinanimation: boolean | null
@Field(() => KlickTipp, { nullable: true })
klickTipp: KlickTipp | null
@Field(() => Boolean, { nullable: true })
hasElopage: boolean | null
} }

View File

@ -27,7 +27,7 @@ import { hasElopageBuys } from '../../util/hasElopageBuys'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User' import { User } from '@entity/User'
import { TransactionTypeId } from '../enum/TransactionTypeId' import { TransactionTypeId } from '../enum/TransactionTypeId'
import { Balance } from '@entity/Balance' import Decimal from 'decimal.js-light'
// const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage? // const EMAIL_OPT_UNKNOWN = 3 // elopage?
@ -307,37 +307,30 @@ export class AdminResolver {
const receivedCallDate = new Date() const receivedCallDate = new Date()
const transactionRepository = getCustomRepository(TransactionRepository) const transactionRepository = getCustomRepository(TransactionRepository)
const lastUserTransaction = await transactionRepository.findLastForUser(pendingCreation.userId) const lastTransaction = await transactionRepository.findLastForUser(pendingCreation.userId)
let newBalance = 0 let newBalance = new Decimal(0)
if (lastUserTransaction) { if (lastTransaction) {
newBalance = calculateDecay( newBalance = calculateDecay(
Number(lastUserTransaction.balance), lastTransaction.balance,
lastUserTransaction.balanceDate, lastTransaction.balanceDate,
receivedCallDate, receivedCallDate,
).balance ).balance
} }
newBalance = Number(newBalance) + Number(parseInt(pendingCreation.amount.toString())) // TODO pending creations decimal
newBalance = newBalance.add(new Decimal(Number(pendingCreation.amount)))
const transaction = new Transaction() const transaction = new Transaction()
transaction.typeId = TransactionTypeId.CREATION transaction.typeId = TransactionTypeId.CREATION
transaction.memo = pendingCreation.memo transaction.memo = pendingCreation.memo
transaction.userId = pendingCreation.userId transaction.userId = pendingCreation.userId
transaction.amount = BigInt(parseInt(pendingCreation.amount.toString())) // TODO pending creations decimal
transaction.amount = new Decimal(Number(pendingCreation.amount))
transaction.creationDate = pendingCreation.date transaction.creationDate = pendingCreation.date
transaction.balance = BigInt(newBalance) transaction.balance = newBalance
transaction.balanceDate = receivedCallDate transaction.balanceDate = receivedCallDate
await transaction.save() await transaction.save()
let userBalance = await Balance.findOne({ userId: pendingCreation.userId })
if (!userBalance) {
userBalance = new Balance()
userBalance.userId = pendingCreation.userId
}
userBalance.amount = Number(newBalance)
userBalance.modified = receivedCallDate
userBalance.recordDate = receivedCallDate
await userBalance.save()
await AdminPendingCreation.delete(pendingCreation) await AdminPendingCreation.delete(pendingCreation)
return true return true

View File

@ -6,9 +6,9 @@ import { getCustomRepository } from '@dbTools/typeorm'
import { Balance } from '../model/Balance' import { Balance } from '../model/Balance'
import { UserRepository } from '../../typeorm/repository/User' import { UserRepository } from '../../typeorm/repository/User'
import { calculateDecay } from '../../util/decay' import { calculateDecay } from '../../util/decay'
import { roundFloorFrom4 } from '../../util/round'
import { RIGHTS } from '../../auth/RIGHTS' import { RIGHTS } from '../../auth/RIGHTS'
import { Balance as dbBalance } from '@entity/Balance' import { Transaction } from '@entity/Transaction'
import Decimal from 'decimal.js-light'
@Resolver() @Resolver()
export class BalanceResolver { export class BalanceResolver {
@ -18,24 +18,26 @@ export class BalanceResolver {
// load user and balance // load user and balance
const userRepository = getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(context.pubKey) const user = await userRepository.findByPubkeyHex(context.pubKey)
const balanceEntity = await dbBalance.findOne({ userId: userEntity.id })
const now = new Date() const now = new Date()
const lastTransaction = await Transaction.findOne(
{ userId: user.id },
{ order: { balanceDate: 'DESC' } },
)
// No balance found // No balance found
if (!balanceEntity) { if (!lastTransaction) {
return new Balance({ return new Balance({
balance: 0, balance: new Decimal(0),
decay: 0, decay: new Decimal(0),
decay_date: now.toString(), decay_date: now.toString(),
}) })
} }
return new Balance({ return new Balance({
balance: roundFloorFrom4(balanceEntity.amount), balance: lastTransaction.balance,
decay: roundFloorFrom4( decay: calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance,
calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now).balance,
),
decay_date: now.toString(), decay_date: now.toString(),
}) })
} }

View File

@ -4,7 +4,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository, getConnection, QueryRunner } from '@dbTools/typeorm' import { getCustomRepository, getConnection } from '@dbTools/typeorm'
import CONFIG from '../../config' import CONFIG from '../../config'
import { sendTransactionReceivedEmail } from '../../mailer/sendTransactionReceivedEmail' import { sendTransactionReceivedEmail } from '../../mailer/sendTransactionReceivedEmail'
@ -22,66 +22,17 @@ import { TransactionRepository } from '../../typeorm/repository/Transaction'
import { User as dbUser } from '@entity/User' import { User as dbUser } from '@entity/User'
import { Transaction as dbTransaction } from '@entity/Transaction' import { Transaction as dbTransaction } from '@entity/Transaction'
import { Balance as dbBalance } from '@entity/Balance'
import { apiPost } from '../../apis/HttpRequest' import { apiPost } from '../../apis/HttpRequest'
import { roundFloorFrom4, roundCeilFrom4 } from '../../util/round'
import { calculateDecay } from '../../util/decay'
import { TransactionTypeId } from '../enum/TransactionTypeId' import { TransactionTypeId } from '../enum/TransactionTypeId'
import { TransactionType } from '../enum/TransactionType' import { calculateBalance, isHexPublicKey } from '../../util/validate'
import { hasUserAmount, isHexPublicKey } from '../../util/validate'
import { RIGHTS } from '../../auth/RIGHTS' import { RIGHTS } from '../../auth/RIGHTS'
import { User } from '../model/User'
import { communityUser } from '../../util/communityUser'
import { virtualDecayTransaction } from '../../util/virtualDecayTransaction'
import Decimal from 'decimal.js-light'
import { calculateDecay } from '../../util/decay'
// helper helper function
async function updateStateBalance(
user: dbUser,
balance: number,
received: Date,
queryRunner: QueryRunner,
): Promise<dbBalance> {
let userBalance = await dbBalance.findOne({ userId: user.id })
if (!userBalance) {
userBalance = new dbBalance()
userBalance.userId = user.id
userBalance.amount = balance
userBalance.modified = received
} else {
userBalance.amount = balance
userBalance.modified = new Date()
}
if (userBalance.amount <= 0) {
throw new Error('error new balance <= 0')
}
userBalance.recordDate = received
return queryRunner.manager.save(userBalance).catch((error) => {
throw new Error('error saving balance:' + error)
})
}
async function calculateNewBalance(
userId: number,
transactionDate: Date,
centAmount: number,
): Promise<number> {
let newBalance = centAmount
const transactionRepository = getCustomRepository(TransactionRepository)
const lastUserTransaction = await transactionRepository.findLastForUser(userId)
if (lastUserTransaction) {
newBalance += Number(
calculateDecay(
Number(lastUserTransaction.balance),
lastUserTransaction.balanceDate,
transactionDate,
).balance,
)
}
if (newBalance <= 0) {
throw new Error('error new balance <= 0')
}
return newBalance
}
@Resolver() @Resolver()
export class TransactionResolver { export class TransactionResolver {
@Authorized([RIGHTS.TRANSACTION_LIST]) @Authorized([RIGHTS.TRANSACTION_LIST])
@ -97,165 +48,92 @@ export class TransactionResolver {
}: Paginated, }: Paginated,
@Ctx() context: any, @Ctx() context: any,
): Promise<TransactionList> { ): Promise<TransactionList> {
// load user const now = new Date()
// find user
const userRepository = getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
// TODO: separate those usecases - this is a security issue
const user = userId const user = userId
? await userRepository.findOneOrFail({ id: userId }, { withDeleted: true }) ? await userRepository.findOneOrFail({ id: userId }, { withDeleted: true })
: await userRepository.findByPubkeyHex(context.pubKey) : await userRepository.findByPubkeyHex(context.pubKey)
let limit = pageSize
let offset = 0 // find current balance
let skipFirstTransaction = false const lastTransaction = await dbTransaction.findOne(
if (currentPage > 1) { { userId: user.id },
offset = (currentPage - 1) * pageSize - 1 { order: { balanceDate: 'DESC' } },
limit++
}
if (offset && order === Order.ASC) {
offset--
}
const transactionRepository = getCustomRepository(TransactionRepository)
const [userTransactions, userTransactionsCount] = await transactionRepository.findByUserPaged(
user.id,
limit,
offset,
order,
onlyCreations,
) )
skipFirstTransaction = userTransactionsCount > offset + limit
const decay = !(currentPage > 1)
const transactions: Transaction[] = []
if (userTransactions.length) {
if (order === Order.DESC) {
userTransactions.reverse()
}
const involvedUserIds: number[] = []
userTransactions.forEach((transaction: dbTransaction) => { // get GDT
involvedUserIds.push(transaction.userId) let balanceGDT = null
if (
transaction.typeId === TransactionTypeId.SEND ||
transaction.typeId === TransactionTypeId.RECEIVE
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
involvedUserIds.push(transaction.linkedUserId!) // TODO ensure not null properly
}
})
// remove duplicates
// https://stackoverflow.com/questions/1960473/get-all-unique-values-in-a-javascript-array-remove-duplicates
const involvedUsersUnique = involvedUserIds.filter((v, i, a) => a.indexOf(v) === i)
const userRepository = getCustomRepository(UserRepository)
const userIndiced = await userRepository.getUsersIndiced(involvedUsersUnique)
for (let i = 0; i < userTransactions.length; i++) {
const userTransaction = userTransactions[i]
const finalTransaction = new Transaction()
finalTransaction.transactionId = userTransaction.id
finalTransaction.date = userTransaction.balanceDate.toISOString()
finalTransaction.memo = userTransaction.memo
finalTransaction.totalBalance = roundFloorFrom4(Number(userTransaction.balance))
const previousTransaction = i > 0 ? userTransactions[i - 1] : null
if (previousTransaction) {
const currentTransaction = userTransaction
const decay = calculateDecay(
Number(previousTransaction.balance),
previousTransaction.balanceDate,
currentTransaction.balanceDate,
)
const balance = Number(previousTransaction.balance) - decay.balance
if (CONFIG.DECAY_START_TIME < currentTransaction.balanceDate) {
finalTransaction.decay = decay
finalTransaction.decay.balance = roundFloorFrom4(balance)
if (
previousTransaction.balanceDate < CONFIG.DECAY_START_TIME &&
currentTransaction.balanceDate > CONFIG.DECAY_START_TIME
) {
finalTransaction.decay.decayStartBlock = (
CONFIG.DECAY_START_TIME.getTime() / 1000
).toString()
}
}
}
finalTransaction.balance = roundFloorFrom4(Number(userTransaction.amount)) // Todo unsafe conversion
const otherUser = userIndiced.find((u) => u.id === userTransaction.linkedUserId)
switch (userTransaction.typeId) {
case TransactionTypeId.CREATION:
finalTransaction.name = 'Gradido Akademie'
finalTransaction.type = TransactionType.CREATION
break
case TransactionTypeId.SEND:
finalTransaction.type = TransactionType.SEND
if (otherUser) {
finalTransaction.name = otherUser.firstName + ' ' + otherUser.lastName
finalTransaction.email = otherUser.email
}
break
case TransactionTypeId.RECEIVE:
finalTransaction.type = TransactionType.RECIEVE
if (otherUser) {
finalTransaction.name = otherUser.firstName + ' ' + otherUser.lastName
finalTransaction.email = otherUser.email
}
break
default:
throw new Error('invalid transaction')
}
if (i > 0 || !skipFirstTransaction) {
transactions.push(finalTransaction)
}
if (i === userTransactions.length - 1 && decay) {
const now = new Date()
const decay = calculateDecay(
Number(userTransaction.balance),
userTransaction.balanceDate,
now,
)
const balance = Number(userTransaction.balance) - decay.balance
const decayTransaction = new Transaction()
decayTransaction.type = 'decay'
decayTransaction.balance = roundCeilFrom4(balance)
decayTransaction.decayDuration = decay.decayDuration
decayTransaction.decayStart = decay.decayStart
decayTransaction.decayEnd = decay.decayEnd
transactions.push(decayTransaction)
}
}
if (order === Order.DESC) {
transactions.reverse()
}
}
const transactionList = new TransactionList()
transactionList.count = userTransactionsCount
transactionList.transactions = transactions
// get gdt sum
transactionList.gdtSum = null
try { try {
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, { const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: user.email, email: user.email,
}) })
if (resultGDTSum.success) transactionList.gdtSum = Number(resultGDTSum.data.sum) || 0 if (!resultGDTSum.success) {
} catch (err: any) {} throw new Error('Call not successful')
}
// get balance balanceGDT = Number(resultGDTSum.data.sum) || 0
const balanceEntity = await dbBalance.findOne({ userId: user.id }) } catch (err: any) {
if (balanceEntity) { // eslint-disable-next-line no-console
const now = new Date() console.log('Could not query GDT Server', err)
transactionList.balance = roundFloorFrom4(balanceEntity.amount)
// TODO: Add a decay object here instead of static data representing the decay.
transactionList.decay = roundFloorFrom4(
calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now).balance,
)
transactionList.decayDate = now.toString()
} }
return transactionList if (!lastTransaction) {
return new TransactionList(new Decimal(0), [], 0, balanceGDT)
}
// find transactions
// first page can contain 26 due to virtual decay transaction
const offset = (currentPage - 1) * pageSize
const transactionRepository = getCustomRepository(TransactionRepository)
const [userTransactions, userTransactionsCount] = await transactionRepository.findByUserPaged(
user.id,
pageSize,
offset,
order,
onlyCreations,
)
// find involved users; I am involved
const involvedUserIds: number[] = [user.id]
userTransactions.forEach((transaction: dbTransaction) => {
if (transaction.linkedUserId && !involvedUserIds.includes(transaction.linkedUserId)) {
involvedUserIds.push(transaction.linkedUserId)
}
})
// We need to show the name for deleted users for old transactions
const involvedDbUsers = await dbUser
.createQueryBuilder()
.withDeleted()
.where('id IN (:...userIds)', { userIds: involvedUserIds })
.getMany()
const involvedUsers = involvedDbUsers.map((u) => new User(u))
const self = new User(user)
const transactions: Transaction[] = []
// decay transaction
if (currentPage === 1 && order === Order.DESC) {
transactions.push(
virtualDecayTransaction(lastTransaction.balance, lastTransaction.balanceDate, now, self),
)
}
// transactions
userTransactions.forEach((userTransaction) => {
const linkedUser =
userTransaction.typeId === TransactionTypeId.CREATION
? communityUser
: involvedUsers.find((u) => u.id === userTransaction.linkedUserId)
transactions.push(new Transaction(userTransaction, self, linkedUser))
})
// Construct Result
return new TransactionList(
calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance,
transactions,
userTransactionsCount,
balanceGDT,
)
} }
@Authorized([RIGHTS.SEND_COINS]) @Authorized([RIGHTS.SEND_COINS])
@ -271,7 +149,9 @@ export class TransactionResolver {
throw new Error('invalid sender public key') throw new Error('invalid sender public key')
} }
// validate amount // validate amount
if (!hasUserAmount(senderUser, amount)) { const receivedCallDate = new Date()
const sendBalance = await calculateBalance(senderUser.id, amount.mul(-1), receivedCallDate)
if (!sendBalance) {
throw new Error("user hasn't enough GDD or amount is < 0") throw new Error("user hasn't enough GDD or amount is < 0")
} }
@ -287,24 +167,22 @@ export class TransactionResolver {
throw new Error('invalid recipient public key') throw new Error('invalid recipient public key')
} }
const centAmount = Math.round(amount * 10000)
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED') await queryRunner.startTransaction('READ UNCOMMITTED')
try { try {
const receivedCallDate = new Date()
// transaction // transaction
const transactionSend = new dbTransaction() const transactionSend = new dbTransaction()
transactionSend.typeId = TransactionTypeId.SEND transactionSend.typeId = TransactionTypeId.SEND
transactionSend.memo = memo transactionSend.memo = memo
transactionSend.userId = senderUser.id transactionSend.userId = senderUser.id
transactionSend.linkedUserId = recipientUser.id transactionSend.linkedUserId = recipientUser.id
transactionSend.amount = BigInt(centAmount) transactionSend.amount = amount.mul(-1)
const sendBalance = await calculateNewBalance(senderUser.id, receivedCallDate, -centAmount) transactionSend.balance = sendBalance.balance
transactionSend.balance = BigInt(Math.trunc(sendBalance))
transactionSend.balanceDate = receivedCallDate transactionSend.balanceDate = receivedCallDate
transactionSend.sendSenderFinalBalance = transactionSend.balance transactionSend.decay = sendBalance.decay.decay
transactionSend.decayStart = sendBalance.decay.start
transactionSend.previous = sendBalance.lastTransactionId
await queryRunner.manager.insert(dbTransaction, transactionSend) await queryRunner.manager.insert(dbTransaction, transactionSend)
const transactionReceive = new dbTransaction() const transactionReceive = new dbTransaction()
@ -312,15 +190,16 @@ export class TransactionResolver {
transactionReceive.memo = memo transactionReceive.memo = memo
transactionReceive.userId = recipientUser.id transactionReceive.userId = recipientUser.id
transactionReceive.linkedUserId = senderUser.id transactionReceive.linkedUserId = senderUser.id
transactionReceive.amount = BigInt(centAmount) transactionReceive.amount = amount
const receiveBalance = await calculateNewBalance( const receiveBalance = await calculateBalance(recipientUser.id, amount, receivedCallDate)
recipientUser.id, if (!receiveBalance) {
receivedCallDate, throw new Error('Sender user account corrupted')
centAmount, }
) transactionReceive.balance = receiveBalance.balance
transactionReceive.balance = BigInt(Math.trunc(receiveBalance))
transactionReceive.balanceDate = receivedCallDate transactionReceive.balanceDate = receivedCallDate
transactionReceive.sendSenderFinalBalance = transactionSend.balance transactionReceive.decay = receiveBalance.decay.decay
transactionReceive.decayStart = receiveBalance.decay.start
transactionReceive.previous = receiveBalance.lastTransactionId
transactionReceive.linkedTransactionId = transactionSend.id transactionReceive.linkedTransactionId = transactionSend.id
await queryRunner.manager.insert(dbTransaction, transactionReceive) await queryRunner.manager.insert(dbTransaction, transactionReceive)
@ -328,17 +207,6 @@ export class TransactionResolver {
transactionSend.linkedTransactionId = transactionReceive.id transactionSend.linkedTransactionId = transactionReceive.id
await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend) await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend)
// Update Balance sender
await updateStateBalance(senderUser, Math.trunc(sendBalance), receivedCallDate, queryRunner)
// Update Balance recipient
await updateStateBalance(
recipientUser,
Math.trunc(receiveBalance),
receivedCallDate,
queryRunner,
)
await queryRunner.commitTransaction() await queryRunner.commitTransaction()
} catch (e) { } catch (e) {
await queryRunner.rollbackTransaction() await queryRunner.rollbackTransaction()
@ -346,6 +214,7 @@ export class TransactionResolver {
} finally { } finally {
await queryRunner.release() await queryRunner.release()
} }
// send notification email // send notification email
// TODO: translate // TODO: translate
await sendTransactionReceivedEmail({ await sendTransactionReceivedEmail({

View File

@ -216,14 +216,8 @@ export class UserResolver {
// TODO refactor and do not have duplicate code with login(see below) // TODO refactor and do not have duplicate code with login(see below)
const userRepository = getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(context.pubKey) const userEntity = await userRepository.findByPubkeyHex(context.pubKey)
const user = new User() const user = new User(userEntity)
user.id = userEntity.id // user.pubkey = userEntity.pubKey.toString('hex')
user.email = userEntity.email
user.firstName = userEntity.firstName
user.lastName = userEntity.lastName
user.pubkey = userEntity.pubKey.toString('hex')
user.language = userEntity.language
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context) user.hasElopage = await this.hasElopage(context)
@ -271,12 +265,9 @@ export class UserResolver {
throw new Error('No user with this credentials') throw new Error('No user with this credentials')
} }
const user = new User() const user = new User(dbUser)
user.id = dbUser.id // user.email = email
user.email = email // user.pubkey = dbUser.pubKey.toString('hex')
user.firstName = dbUser.firstName
user.lastName = dbUser.lastName
user.pubkey = dbUser.pubKey.toString('hex')
user.language = dbUser.language user.language = dbUser.language
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId

View File

@ -0,0 +1,23 @@
import { GraphQLScalarType, Kind } from 'graphql'
import Decimal from 'decimal.js-light'
export default new GraphQLScalarType({
name: 'Decimal',
description: 'The `Decimal` scalar type to represent currency values',
serialize(value: Decimal) {
return value.toString()
},
parseValue(value) {
return new Decimal(value)
},
parseLiteral(ast) {
if (ast.kind !== Kind.STRING) {
throw new TypeError(`${String(ast)} is not a valid decimal value.`)
}
return new Decimal(ast.value)
},
})

View File

@ -3,11 +3,14 @@ import { buildSchema } from 'type-graphql'
import path from 'path' import path from 'path'
import isAuthorized from './directive/isAuthorized' import isAuthorized from './directive/isAuthorized'
import DecimalScalar from './scalar/Decimal'
import Decimal from 'decimal.js-light'
const schema = async (): Promise<GraphQLSchema> => { const schema = async (): Promise<GraphQLSchema> => {
return buildSchema({ return buildSchema({
resolvers: [path.join(__dirname, 'resolver', `!(*.test).{js,ts}`)], resolvers: [path.join(__dirname, 'resolver', `!(*.test).{js,ts}`)],
authChecker: isAuthorized, authChecker: isAuthorized,
scalarsMap: [{ type: Decimal, scalar: DecimalScalar }],
}) })
} }

View File

@ -1,5 +1,6 @@
import { sendTransactionReceivedEmail } from './sendTransactionReceivedEmail' import { sendTransactionReceivedEmail } from './sendTransactionReceivedEmail'
import { sendEMail } from './sendEMail' import { sendEMail } from './sendEMail'
import Decimal from 'decimal.js-light'
jest.mock('./sendEMail', () => { jest.mock('./sendEMail', () => {
return { return {
@ -16,7 +17,7 @@ describe('sendTransactionReceivedEmail', () => {
recipientFirstName: 'Peter', recipientFirstName: 'Peter',
recipientLastName: 'Lustig', recipientLastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 42.0, amount: new Decimal(42.0),
memo: 'Vielen herzlichen Dank für den neuen Hexenbesen!', memo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
}) })
}) })

View File

@ -1,3 +1,4 @@
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail' import { sendEMail } from './sendEMail'
import { transactionReceived } from './text/transactionReceived' import { transactionReceived } from './text/transactionReceived'
@ -7,7 +8,7 @@ export const sendTransactionReceivedEmail = (data: {
recipientFirstName: string recipientFirstName: string
recipientLastName: string recipientLastName: string
email: string email: string
amount: number amount: Decimal
memo: string memo: string
}): Promise<boolean> => { }): Promise<boolean> => {
return sendEMail({ return sendEMail({

View File

@ -1,3 +1,5 @@
import Decimal from 'decimal.js-light'
export const transactionReceived = { export const transactionReceived = {
de: { de: {
subject: 'Gradido Überweisung', subject: 'Gradido Überweisung',
@ -7,7 +9,7 @@ export const transactionReceived = {
recipientFirstName: string recipientFirstName: string
recipientLastName: string recipientLastName: string
email: string email: string
amount: number amount: Decimal
memo: string memo: string
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName} `Hallo ${data.recipientFirstName} ${data.recipientLastName}

View File

@ -9,14 +9,6 @@ export class UserRepository extends Repository<User> {
.getOneOrFail() .getOneOrFail()
} }
async getUsersIndiced(userIds: number[]): Promise<User[]> {
return this.createQueryBuilder('user')
.withDeleted() // We need to show the name for deleted users for old transactions
.select(['user.id', 'user.firstName', 'user.lastName', 'user.email'])
.where('user.id IN (:...userIds)', { userIds })
.getMany()
}
async findBySearchCriteriaPagedFiltered( async findBySearchCriteriaPagedFiltered(
select: string[], select: string[],
searchCriteria: string, searchCriteria: string,

View File

@ -0,0 +1,44 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User'
import { User } from '../graphql/model/User'
const communityDbUser: dbUser = {
id: -1,
email: 'support@gradido.net',
firstName: 'Gradido',
lastName: 'Akademie',
pubKey: Buffer.from(''),
privKey: Buffer.from(''),
deletedAt: null,
password: BigInt(0),
emailHash: Buffer.from(''),
createdAt: new Date(),
emailChecked: false,
language: '',
publisherId: 0,
passphrase: '',
settings: [],
hasId: function (): boolean {
throw new Error('Function not implemented.')
},
save: function (options?: SaveOptions): Promise<dbUser> {
throw new Error('Function not implemented.')
},
remove: function (options?: RemoveOptions): Promise<dbUser> {
throw new Error('Function not implemented.')
},
softRemove: function (options?: SaveOptions): Promise<dbUser> {
throw new Error('Function not implemented.')
},
recover: function (options?: SaveOptions): Promise<dbUser> {
throw new Error('Function not implemented.')
},
reload: function (): Promise<void> {
throw new Error('Function not implemented.')
},
}
const communityUser = new User(communityDbUser)
export { communityDbUser, communityUser }

View File

@ -1,41 +1,41 @@
import Decimal from 'decimal.js-light'
import 'reflect-metadata' // This might be wise to load in a test setup file import 'reflect-metadata' // This might be wise to load in a test setup file
import { decayFormula, calculateDecay } from './decay' import { decayFormula, calculateDecay } from './decay'
describe('utils/decay', () => { describe('utils/decay', () => {
describe('decayFormula', () => { describe('decayFormula', () => {
it('has base 0.99999997802044727', () => { it('has base 0.99999997802044727', () => {
const amount = 1.0 const amount = new Decimal(1.0)
const seconds = 1 const seconds = 1
expect(decayFormula(amount, seconds)).toBe(0.99999997802044727) // TODO: toString() was required, we could not compare two decimals
}) expect(decayFormula(amount, seconds).toString()).toBe('0.999999978035040489732012')
// Not sure if the following skiped tests make sence!?
it('has negative decay?', async () => {
const amount = -1.0
const seconds = 1
expect(await decayFormula(amount, seconds)).toBe(-0.99999997802044727)
}) })
it('has correct backward calculation', async () => { it('has correct backward calculation', async () => {
const amount = 1.0 const amount = new Decimal(1.0)
const seconds = -1 const seconds = -1
expect(await decayFormula(amount, seconds)).toBe(1.0000000219795533) expect(decayFormula(amount, seconds).toString()).toBe('1.000000021964959992727444')
}) })
// not possible, nodejs hasn't enough accuracy // we get pretty close, but not exact here, skipping
it('has correct forward calculation', async () => { it.skip('has correct forward calculation', async () => {
const amount = 1.0 / 0.99999997802044727 const amount = new Decimal(1.0).div(
new Decimal('0.99999997803504048973201202316767079413460520837376'),
)
const seconds = 1 const seconds = 1
expect(await decayFormula(amount, seconds)).toBe(1.0) expect(decayFormula(amount, seconds).toString()).toBe('1.0')
}) })
}) })
it.skip('has base 0.99999997802044727', async () => { it('has base 0.99999997802044727', async () => {
const now = new Date() const now = new Date()
now.setSeconds(1) now.setSeconds(1)
const oneSecondAgo = new Date(now.getTime()) const oneSecondAgo = new Date(now.getTime())
oneSecondAgo.setSeconds(0) oneSecondAgo.setSeconds(0)
expect(await calculateDecay(1.0, oneSecondAgo, now)).toBe(0.99999997802044727) expect(calculateDecay(new Decimal(1.0), oneSecondAgo, now).balance.toString()).toBe(
'0.999999978035040489732012',
)
}) })
it('returns input amount when from and to is the same', async () => { it('returns input amount when from and to is the same', async () => {
const now = new Date() const now = new Date()
expect((await calculateDecay(100.0, now, now)).balance).toBe(100.0) expect(calculateDecay(new Decimal(100.0), now, now).balance.toString()).toBe('100')
}) })
}) })

View File

@ -1,45 +1,57 @@
import Decimal from 'decimal.js-light'
import CONFIG from '../config' import CONFIG from '../config'
import { Decay } from '../graphql/model/Decay' import { Decay } from '../graphql/model/Decay'
function decayFormula(amount: number, seconds: number): number { // TODO: externalize all those definitions and functions into an external decay library
return amount * Math.pow(0.99999997802044727, seconds) // This number represents 50% decay a year
function decayFormula(value: Decimal, seconds: number): Decimal {
// TODO why do we need to convert this here to a stting to work properly?
return value.mul(
new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds).toString(),
)
} }
function calculateDecay(amount: number, from: Date, to: Date): Decay { function calculateDecay(
amount: Decimal,
from: Date,
to: Date,
startBlock: Date = CONFIG.DECAY_START_TIME,
): Decay {
const fromMs = from.getTime() const fromMs = from.getTime()
const toMs = to.getTime() const toMs = to.getTime()
const decayStartBlockMs = CONFIG.DECAY_START_TIME.getTime() const startBlockMs = startBlock.getTime()
if (toMs < fromMs) { if (toMs < fromMs) {
throw new Error('to < from, reverse decay calculation is invalid') throw new Error('to < from, reverse decay calculation is invalid')
} }
// Initialize with no decay // Initialize with no decay
const decay = new Decay({ const decay: Decay = {
balance: amount, balance: amount,
decayStart: null, decay: new Decimal(0),
decayEnd: null, start: null,
decayDuration: 0, end: null,
decayStartBlock: (decayStartBlockMs / 1000).toString(), duration: null,
}) }
// decay started after end date; no decay // decay started after end date; no decay
if (decayStartBlockMs > toMs) { if (startBlockMs > toMs) {
return decay return decay
} }
// decay started before start date; decay for full duration // decay started before start date; decay for full duration
else if (decayStartBlockMs < fromMs) { if (startBlockMs < fromMs) {
decay.decayStart = (fromMs / 1000).toString() decay.start = from
decay.decayDuration = (toMs - fromMs) / 1000 decay.duration = (toMs - fromMs) / 1000
} }
// decay started between start and end date; decay from decay start till end date // decay started between start and end date; decay from decay start till end date
else { else {
decay.decayStart = (decayStartBlockMs / 1000).toString() decay.start = startBlock
decay.decayDuration = (toMs - decayStartBlockMs) / 1000 decay.duration = (toMs - startBlockMs) / 1000
} }
decay.decayEnd = (toMs / 1000).toString() decay.end = to
decay.balance = decayFormula(amount, decay.decayDuration) decay.balance = decayFormula(amount, decay.duration)
decay.decay = decay.balance.minus(amount)
return decay return decay
} }

View File

@ -1,22 +0,0 @@
import { roundCeilFrom4, roundFloorFrom4, roundCeilFrom2, roundFloorFrom2 } from './round'
describe('utils/round', () => {
it('roundCeilFrom4', () => {
const amount = 11617
expect(roundCeilFrom4(amount)).toBe(1.17)
})
// Not sure if the following skiped tests make sence!?
it('roundFloorFrom4', () => {
const amount = 11617
expect(roundFloorFrom4(amount)).toBe(1.16)
})
it('roundCeilFrom2', () => {
const amount = 1216
expect(roundCeilFrom2(amount)).toBe(13)
})
// not possible, nodejs hasn't enough accuracy
it('roundFloorFrom2', () => {
const amount = 1216
expect(roundFloorFrom2(amount)).toBe(12)
})
})

View File

@ -1,17 +0,0 @@
function roundCeilFrom4(decimal: number): number {
return Math.ceil(decimal / 100) / 100
}
function roundFloorFrom4(decimal: number): number {
return Math.floor(decimal / 100) / 100
}
function roundCeilFrom2(decimal: number): number {
return Math.ceil(decimal / 100)
}
function roundFloorFrom2(decimal: number): number {
return Math.floor(decimal / 100)
}
export { roundCeilFrom4, roundFloorFrom4, roundCeilFrom2, roundFloorFrom2 }

View File

@ -1,7 +1,7 @@
import { User as dbUser } from '@entity/User'
import { Balance as dbBalance } from '@entity/Balance'
import { getRepository } from '@dbTools/typeorm'
import { calculateDecay } from './decay' import { calculateDecay } from './decay'
import Decimal from 'decimal.js-light'
import { Transaction } from '@entity/Transaction'
import { Decay } from '../graphql/model/Decay'
function isStringBoolean(value: string): boolean { function isStringBoolean(value: string): boolean {
const lowerValue = value.toLowerCase() const lowerValue = value.toLowerCase()
@ -15,14 +15,21 @@ function isHexPublicKey(publicKey: string): boolean {
return /^[0-9A-Fa-f]{64}$/i.test(publicKey) return /^[0-9A-Fa-f]{64}$/i.test(publicKey)
} }
async function hasUserAmount(user: dbUser, amount: number): Promise<boolean> { async function calculateBalance(
if (amount < 0) return false userId: number,
const balanceRepository = getRepository(dbBalance) amount: Decimal,
const balance = await balanceRepository.findOne({ userId: user.id }) time: Date,
if (!balance) return false ): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> {
const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } })
if (!lastTransaction) return null
const decay = calculateDecay(balance.amount, balance.recordDate, new Date()).balance const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)
return decay > amount // TODO why we have to use toString() here?
const balance = decay.balance.add(amount.toString())
if (balance.lessThan(0)) {
return null
}
return { balance, lastTransactionId: lastTransaction.id, decay }
} }
export { isHexPublicKey, hasUserAmount, isStringBoolean } export { isHexPublicKey, calculateBalance, isStringBoolean }

View File

@ -0,0 +1,52 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Decimal from 'decimal.js-light'
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { Transaction as dbTransaction } from '@entity/Transaction'
import { calculateDecay } from './decay'
import { TransactionTypeId } from '../graphql/enum/TransactionTypeId'
import { Transaction } from '../graphql/model/Transaction'
import { User } from '../graphql/model/User'
const virtualDecayTransaction = (
balance: Decimal,
balanceDate: Date,
time: Date = new Date(),
user: User,
): Transaction => {
const decay = calculateDecay(balance, balanceDate, time)
// const balance = decay.balance.minus(lastTransaction.balance)
const decayDbTransaction: dbTransaction = {
id: -1,
userId: -1,
previous: -1,
typeId: TransactionTypeId.DECAY,
amount: decay.decay ? decay.decay : new Decimal(0), // new Decimal(0), // this kinda is wrong, but helps with the frontend query
balance: decay.balance,
balanceDate: time,
decay: decay.decay ? decay.decay : new Decimal(0),
decayStart: decay.start,
memo: '',
creationDate: null,
hasId: function (): boolean {
throw new Error('Function not implemented.')
},
save: function (options?: SaveOptions): Promise<dbTransaction> {
throw new Error('Function not implemented.')
},
remove: function (options?: RemoveOptions): Promise<dbTransaction> {
throw new Error('Function not implemented.')
},
softRemove: function (options?: SaveOptions): Promise<dbTransaction> {
throw new Error('Function not implemented.')
},
recover: function (options?: SaveOptions): Promise<dbTransaction> {
throw new Error('Function not implemented.')
},
reload: function (): Promise<void> {
throw new Error('Function not implemented.')
},
}
return new Transaction(decayDbTransaction, user)
}
export { virtualDecayTransaction }

View File

@ -1961,6 +1961,11 @@ debug@^3.2.6, debug@^3.2.7:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
decimal.js-light@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
decimal.js@^10.2.1: decimal.js@^10.2.1:
version "10.3.1" version "10.3.1"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783"

View File

@ -1,5 +1,5 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm' import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm'
import { Balance } from '../Balance' import { Balance } from './Balance'
// Moriz: I do not like the idea of having two user tables // Moriz: I do not like the idea of having two user tables
@Entity('state_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) @Entity('state_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })

View File

@ -0,0 +1,146 @@
import Decimal from 'decimal.js-light'
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
@Entity('transactions')
export class Transaction extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@Column({ unsigned: true, nullable: true, default: null })
previous: number
@Column({ name: 'type_id', unsigned: true, nullable: false })
typeId: number
@Column({
name: 'dec_amount',
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
decAmount: Decimal
@Column({
name: 'dec_balance',
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
decBalance: Decimal
@Column({
name: 'balance_date',
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
nullable: false,
})
balanceDate: Date
@Column({
name: 'dec_decay',
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
decDecay: Decimal
@Column({
name: 'decay_start',
type: 'datetime',
nullable: true,
default: null,
})
decayStart: Date | null
@Column({ type: 'bigint', nullable: false })
amount: BigInt
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({
name: 'send_sender_final_balance',
type: 'bigint',
nullable: true,
default: null,
})
sendSenderFinalBalance: BigInt | null
@Column({ name: 'balance', type: 'bigint', default: 0 })
balance: BigInt
@Column({ name: 'creation_date', type: 'datetime', nullable: true, default: null })
creationDate: Date
@Column({
name: 'linked_user_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
linkedUserId?: number | null
@Column({
name: 'linked_transaction_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
linkedTransactionId?: number | null
@Column({
name: 'temp_dec_send_sender_final_balance',
type: 'decimal',
precision: 40,
scale: 20,
nullable: true,
default: null,
transformer: DecimalTransformer,
})
tempDecSendSenderFinalBalance: Decimal
@Column({
name: 'temp_dec_diff_send_sender_final_balance',
type: 'decimal',
precision: 40,
scale: 20,
nullable: true,
default: null,
transformer: DecimalTransformer,
})
tempDecDiffSendSenderFinalBalance: Decimal
@Column({
name: 'temp_dec_old_balance',
type: 'decimal',
precision: 40,
scale: 20,
nullable: true,
default: null,
transformer: DecimalTransformer,
})
tempDecOldBalance: Decimal
@Column({
name: 'temp_dec_diff_balance',
type: 'decimal',
precision: 40,
scale: 20,
nullable: true,
default: null,
transformer: DecimalTransformer,
})
tempDecDiffBalance: Decimal
}

View File

@ -0,0 +1,85 @@
import Decimal from 'decimal.js-light'
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
@Entity('transactions')
export class Transaction extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@Column({ unsigned: true, nullable: true, default: null })
previous: number
@Column({ name: 'type_id', unsigned: true, nullable: false })
typeId: number
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
balance: Decimal
@Column({
name: 'balance_date',
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
nullable: false,
})
balanceDate: Date
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
decay: Decimal
@Column({
name: 'decay_start',
type: 'datetime',
nullable: true,
default: null,
})
decayStart: Date | null
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({ name: 'creation_date', type: 'datetime', nullable: true, default: null })
creationDate: Date | null
@Column({
name: 'linked_user_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
linkedUserId?: number | null
@Column({
name: 'linked_transaction_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
linkedTransactionId?: number | null
}

View File

@ -1 +0,0 @@
export { Balance } from './0001-init_db/Balance'

View File

@ -1 +1 @@
export { Transaction } from './0027-clean_transaction_table/Transaction' export { Transaction } from './0029-clean_transaction_table/Transaction'

View File

@ -1,4 +1,3 @@
import { Balance } from './Balance'
import { LoginElopageBuys } from './LoginElopageBuys' import { LoginElopageBuys } from './LoginElopageBuys'
import { LoginEmailOptIn } from './LoginEmailOptIn' import { LoginEmailOptIn } from './LoginEmailOptIn'
import { Migration } from './Migration' import { Migration } from './Migration'
@ -10,7 +9,6 @@ import { AdminPendingCreation } from './AdminPendingCreation'
export const entities = [ export const entities = [
AdminPendingCreation, AdminPendingCreation,
Balance,
LoginElopageBuys, LoginElopageBuys,
LoginEmailOptIn, LoginEmailOptIn,
Migration, Migration,

View File

@ -0,0 +1,231 @@
/* MIGRATION TO INTRODUCE THE DECIMAL TYPE
*
* This migration adds fields of type DECIMAL
* and corrects the corresponding values of
* each by recalculating the history of all
* user transactions.
*
* Furthermore it increases precision of the
* stored values and stores additional data
* points to avoid repetitive calculations.
*
* It will also add a link to the last
* transaction, creating a linked list
*
* And it will convert all timestamps to
* datetime.
*
* WARNING: This Migration must be run in TZ=UTC
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import Decimal from 'decimal.js-light'
// Set precision value
Decimal.set({
precision: 25,
rounding: Decimal.ROUND_HALF_UP,
})
const DECAY_START_TIME = new Date('2021-05-13 17:46:31') // GMT+0
// TODO: externalize all those definitions and functions into an external decay library
interface Decay {
balance: Decimal
decay: Decimal | null
start: Date | null
end: Date | null
duration: number | null
}
export enum TransactionTypeId {
CREATION = 1,
SEND = 2,
RECEIVE = 3,
}
function decayFormula(value: Decimal, seconds: number): Decimal {
return value.mul(new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds))
}
function calculateDecay(
amount: Decimal,
from: Date,
to: Date,
startBlock: Date = DECAY_START_TIME,
): Decay {
const fromMs = from.getTime()
const toMs = to.getTime()
const startBlockMs = startBlock.getTime()
if (toMs < fromMs) {
throw new Error('to < from, reverse decay calculation is invalid')
}
// Initialize with no decay
const decay: Decay = {
balance: amount,
decay: null,
start: null,
end: null,
duration: null,
}
// decay started after end date; no decay
if (startBlockMs > toMs) {
return decay
}
// decay started before start date; decay for full duration
if (startBlockMs < fromMs) {
decay.start = from
decay.duration = (toMs - fromMs) / 1000
}
// decay started between start and end date; decay from decay start till end date
else {
decay.start = startBlock
decay.duration = (toMs - startBlockMs) / 1000
}
decay.end = to
decay.balance = decayFormula(amount, decay.duration)
decay.decay = decay.balance.minus(amount)
return decay
}
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
// Add Columns
// add column `previous` for a link to the previous transaction
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `previous` int(10) unsigned DEFAULT NULL AFTER `user_id`;',
)
// add column `dec_amount` with temporary NULL and DEFAULT NULL definition
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `dec_amount` DECIMAL(40,20) NULL DEFAULT NULL AFTER `type_id`;',
)
// add column `dec_balance` with temporary NULL and DEFAULT NULL definition
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `dec_balance` DECIMAL(40,20) NULL DEFAULT NULL AFTER `dec_amount`;',
)
// add new column `dec_decay` with temporary NULL and DEFAULT NULL definition
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `dec_decay` DECIMAL(40,20) NULL DEFAULT NULL AFTER `dec_balance`;',
)
// add new column `decay_start`
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `decay_start` datetime DEFAULT NULL AFTER `dec_decay`;',
)
// Modify columns
// modify date type of `balance_date` to datetime
await queryFn(
'ALTER TABLE `transactions` MODIFY COLUMN `balance_date` datetime NOT NULL DEFAULT current_timestamp() AFTER `dec_balance`;',
)
// modify date type of `creation_date` to datetime
await queryFn(
'ALTER TABLE `transactions` MODIFY COLUMN `creation_date` datetime DEFAULT NULL AFTER `balance`;',
)
// Temporary columns
// temporary decimal column `temp_dec_send_sender_final_balance`
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_send_sender_final_balance` DECIMAL(40,20) NULL DEFAULT NULL AFTER `linked_transaction_id`;',
)
// temporary decimal column `temp_dec_diff_send_sender_final_balance`
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_diff_send_sender_final_balance` DECIMAL(40,20) NULL DEFAULT NULL AFTER `temp_dec_send_sender_final_balance`;',
)
// temporary decimal column `temp_dec_old_balance`
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_old_balance` DECIMAL(40,20) NULL DEFAULT NULL AFTER `temp_dec_diff_send_sender_final_balance`;',
)
// temporary decimal column `temp_dec_diff_balance`
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_diff_balance` DECIMAL(40,20) NULL DEFAULT NULL AFTER `temp_dec_old_balance`;',
)
// Find all users & loop over them
const users = await queryFn('SELECT user_id FROM transactions GROUP BY user_id')
for (let u = 0; u < users.length; u++) {
// find all transactions for a user
const transactions = await queryFn(
`SELECT * FROM transactions WHERE user_id = ${users[u].user_id} ORDER BY balance_date ASC;`,
)
let previous = null
let balance = new Decimal(0)
for (let t = 0; t < transactions.length; t++) {
const transaction = transactions[t]
// This should also fix the rounding error on amount
let decAmount = new Decimal(transaction.amount).dividedBy(10000).toDecimalPlaces(2)
if (transaction.type_id === TransactionTypeId.SEND) {
decAmount = decAmount.mul(-1)
}
const decayStartDate = previous ? previous.balance_date : transaction.balance_date
const decay = calculateDecay(balance, decayStartDate, transaction.balance_date)
// WARNING: `toISOString()` needs UTC Timezone to work properly!
const decayStart =
previous && decay.start
? '"' + decay.start.toISOString().slice(0, 19).replace('T', ' ') + '"'
: null
balance = decay.balance.add(decAmount)
const tempDecSendSenderFinalBalance = transaction.send_sender_final_balance
? new Decimal(transaction.send_sender_final_balance).dividedBy(10000)
: null
const tempDecDiffSendSenderFinalBalance = tempDecSendSenderFinalBalance
? balance.minus(tempDecSendSenderFinalBalance)
: null
const tempDecOldBalance = new Decimal(transaction.balance).dividedBy(10000)
const tempDecDiffBalance = balance.minus(tempDecOldBalance)
// Update
await queryFn(`
UPDATE transactions SET
previous = ${previous ? previous.id : null},
dec_amount = ${decAmount.toString()},
dec_balance = ${balance.toString()},
dec_decay = ${decay.decay ? decay.decay.toString() : '0'},
decay_start = ${decayStart},
temp_dec_send_sender_final_balance = ${
tempDecSendSenderFinalBalance ? tempDecSendSenderFinalBalance.toString() : null
},
temp_dec_diff_send_sender_final_balance = ${
tempDecDiffSendSenderFinalBalance ? tempDecDiffSendSenderFinalBalance.toString() : null
},
temp_dec_old_balance = ${tempDecOldBalance.toString()},
temp_dec_diff_balance = ${tempDecDiffBalance.toString()}
WHERE id = ${transaction.id};
`)
// previous
previous = transaction
}
}
// Remove null as value & default value from `dec_amount`, `dec_balance` and `dec_decay`
await queryFn('ALTER TABLE `transactions` MODIFY COLUMN `dec_amount` DECIMAL(40,20) NOT NULL;')
await queryFn('ALTER TABLE `transactions` MODIFY COLUMN `dec_balance` DECIMAL(40,20) NOT NULL;')
await queryFn('ALTER TABLE `transactions` MODIFY COLUMN `dec_decay` DECIMAL(40,20) NOT NULL;')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_diff_balance`')
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_old_balance`;')
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_diff_send_sender_final_balance`;')
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_send_sender_final_balance`;')
await queryFn(
'ALTER TABLE `transactions` MODIFY COLUMN `creation_date` timestamp NULL DEFAULT NULL AFTER `balance`;',
)
await queryFn(
'ALTER TABLE `transactions` MODIFY COLUMN `balance_date` timestamp NOT NULL DEFAULT current_timestamp() AFTER `dec_balance`;',
)
await queryFn('ALTER TABLE `transactions` DROP COLUMN `decay_start`;')
await queryFn('ALTER TABLE `transactions` DROP COLUMN `dec_decay`;')
await queryFn('ALTER TABLE `transactions` DROP COLUMN `dec_balance`;')
await queryFn('ALTER TABLE `transactions` DROP COLUMN `dec_amount`;')
await queryFn('ALTER TABLE `transactions` DROP COLUMN `previous`;')
}

View File

@ -0,0 +1,105 @@
/* MIGRATION TO CLEAN THE TRANSACTION TABLE
*
* This migration deletes and renames several
* columns of the `transactions` table.
*
* This migration has data loss
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
// Delete columns
// delete column `amount`
await queryFn('ALTER TABLE `transactions` DROP COLUMN `amount`;')
// delete column `send_sender_final_balance`
await queryFn('ALTER TABLE `transactions` DROP COLUMN `send_sender_final_balance`;')
// delete column `balance`
await queryFn('ALTER TABLE `transactions` DROP COLUMN `balance`;')
// delete column `temp_dec_send_sender_final_balance`
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_send_sender_final_balance`;')
// delete column `temp_dec_diff_send_sender_final_balance`
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_diff_send_sender_final_balance`;')
// delete column `temp_dec_old_balance`
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_old_balance`;')
// delete column `temp_dec_diff_balance`
await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_diff_balance`;')
// Rename columns
// rename column `dec_amount` to `amount`
await queryFn('ALTER TABLE `transactions` RENAME COLUMN `dec_amount` to `amount`;')
// rename column `dec_balance` to `balance`
await queryFn('ALTER TABLE `transactions` RENAME COLUMN `dec_balance` to `balance`;')
// rename column `dec_decay` to `decay`
await queryFn('ALTER TABLE `transactions` RENAME COLUMN `dec_decay` to `decay`;')
// Drop tables
// drop `state_balances`
await queryFn('DROP TABLE `state_balances`;')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
// Not all data is recoverable here, this data is simulated,
// We lose all incorrect balances and wrongly rounded amounts.
await queryFn(`
CREATE TABLE \`state_balances\` (
\`id\` int(10) unsigned NOT NULL AUTO_INCREMENT,
\`state_user_id\` int(10) unsigned NOT NULL,
\`modified\` datetime NOT NULL,
\`record_date\` datetime DEFAULT NULL,
\`amount\` bigint(20) NOT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB AUTO_INCREMENT=568 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`)
await queryFn(`
INSERT INTO \`state_balances\`
(state_user_id, modified, record_date, amount)
SELECT user_id as state_user_id, balance_date as modified, balance_date as record_date, amount * 10000 as amount FROM
(SELECT user_id as uid, MAX(balance_date) AS date FROM transactions GROUP BY uid) AS t
LEFT JOIN transactions ON t.uid = transactions.user_id AND t.date = transactions.balance_date;
`)
await queryFn('ALTER TABLE `transactions` RENAME COLUMN `decay` to `dec_decay`;')
await queryFn('ALTER TABLE `transactions` RENAME COLUMN `balance` to `dec_balance`;')
await queryFn('ALTER TABLE `transactions` RENAME COLUMN `amount` to `dec_amount`;')
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_diff_balance` decimal(40,20) DEFAULT NULL AFTER linked_transaction_id;',
)
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_old_balance` decimal(40,20) DEFAULT NULL AFTER linked_transaction_id;',
)
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_diff_send_sender_final_balance` decimal(40,20) DEFAULT NULL AFTER linked_transaction_id;',
)
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `temp_dec_send_sender_final_balance` decimal(40,20) DEFAULT NULL AFTER linked_transaction_id;',
)
await queryFn('ALTER TABLE `transactions` ADD COLUMN `balance` bigint(20) DEFAULT 0 AFTER memo;')
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `send_sender_final_balance` bigint(20) DEFAULT NULL AFTER memo;',
)
await queryFn(
'ALTER TABLE `transactions` ADD COLUMN `amount` bigint(20) DEFAULT NULL AFTER decay_start;',
)
await queryFn(`
UPDATE transactions SET
temp_dec_diff_balance = 0,
temp_dec_old_balance = dec_balance,
temp_dec_diff_send_sender_final_balance = 0,
temp_dec_send_sender_final_balance = dec_balance,
balance = dec_balance * 10000,
send_sender_final_balance = dec_balance * 10000,
amount = dec_amount * 10000;
`)
await queryFn('ALTER TABLE `transactions` MODIFY COLUMN `amount` bigint(20) NOT NULL;')
}

View File

@ -10,15 +10,15 @@
"scripts": { "scripts": {
"build": "mkdir -p build/src/config/ && cp src/config/*.txt build/src/config/ && tsc --build", "build": "mkdir -p build/src/config/ && cp src/config/*.txt build/src/config/ && tsc --build",
"clean": "tsc --build --clean", "clean": "tsc --build --clean",
"up": "node build/src/index.js up", "up": "TZ=UTC node build/src/index.js up",
"down": "node build/src/index.js down", "down": "TZ=UTC node build/src/index.js down",
"reset": "node build/src/index.js reset", "reset": "TZ=UTC node build/src/index.js reset",
"dev_up": "ts-node src/index.ts up", "dev_up": "TZ=UTC ts-node src/index.ts up",
"dev_down": "ts-node src/index.ts down", "dev_down": "TZ=UTC ts-node src/index.ts down",
"dev_reset": "ts-node src/index.ts reset", "dev_reset": "TZ=UTC ts-node src/index.ts reset",
"lint": "eslint --max-warnings=0 --ext .js,.ts .", "lint": "eslint --max-warnings=0 --ext .js,.ts .",
"seed:config": "ts-node ./node_modules/typeorm-seeding/dist/cli.js config", "seed:config": "ts-node ./node_modules/typeorm-seeding/dist/cli.js config",
"seed": "ts-node src/index.ts seed" "seed": "TZ=UTC ts-node src/index.ts seed"
}, },
"devDependencies": { "devDependencies": {
"@types/faker": "^5.5.9", "@types/faker": "^5.5.9",
@ -38,6 +38,7 @@
}, },
"dependencies": { "dependencies": {
"crypto": "^1.0.1", "crypto": "^1.0.1",
"decimal.js-light": "^2.5.1",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"faker": "^5.5.3", "faker": "^5.5.3",
"mysql2": "^2.3.0", "mysql2": "^2.3.0",

View File

@ -1,18 +0,0 @@
import Faker from 'faker'
import { define } from 'typeorm-seeding'
import { Balance } from '../../entity/Balance'
import { BalanceContext } from '../interface/TransactionContext'
define(Balance, (faker: typeof Faker, context?: BalanceContext) => {
if (!context || !context.user) {
throw new Error('Balance: No user present!')
}
const balance = new Balance()
balance.modified = context.modified ? context.modified : faker.date.recent()
balance.recordDate = context.recordDate ? context.recordDate : faker.date.recent()
balance.amount = context.amount ? context.amount : 10000000
balance.user = context.user
return balance
})

View File

@ -2,6 +2,7 @@ import Faker from 'faker'
import { define } from 'typeorm-seeding' import { define } from 'typeorm-seeding'
import { Transaction } from '../../entity/Transaction' import { Transaction } from '../../entity/Transaction'
import { TransactionContext } from '../interface/TransactionContext' import { TransactionContext } from '../interface/TransactionContext'
import Decimal from 'decimal.js-light'
define(Transaction, (faker: typeof Faker, context?: TransactionContext) => { define(Transaction, (faker: typeof Faker, context?: TransactionContext) => {
if (!context) { if (!context) {
@ -12,11 +13,12 @@ define(Transaction, (faker: typeof Faker, context?: TransactionContext) => {
transaction.typeId = context.typeId // || 2 transaction.typeId = context.typeId // || 2
transaction.userId = context.userId transaction.userId = context.userId
transaction.amount = context.amount transaction.amount = context.amount
transaction.balance = context.balance
transaction.decay = new Decimal(0) // context.decay
transaction.memo = context.memo transaction.memo = context.memo
transaction.creationDate = context.creationDate || new Date() transaction.creationDate = context.creationDate || new Date()
// transaction.sendReceiverPublicKey = context.sendReceiverPublicKey || null // transaction.sendReceiverPublicKey = context.sendReceiverPublicKey || null
transaction.linkedUserId = context.sendReceiverUserId || null transaction.linkedUserId = context.sendReceiverUserId || null
transaction.sendSenderFinalBalance = context.sendSenderFinalBalance || null
return transaction return transaction
}) })

View File

@ -1,20 +1,12 @@
import { User } from '../../entity/User' import Decimal from 'decimal.js-light'
export interface TransactionContext { export interface TransactionContext {
typeId: number typeId: number
userId: number userId: number
balance: BigInt balance: Decimal
balanceDate: Date balanceDate: Date
amount: BigInt amount: Decimal
memo: string memo: string
creationDate?: Date creationDate?: Date
sendReceiverUserId?: number sendReceiverUserId?: number
sendSenderFinalBalance?: BigInt
}
export interface BalanceContext {
modified?: Date
recordDate?: Date
amount?: number
user?: User
} }

View File

@ -1,3 +1,5 @@
import Decimal from 'decimal.js-light'
export interface UserInterface { export interface UserInterface {
// from user // from user
email?: string email?: string
@ -24,10 +26,7 @@ export interface UserInterface {
// flag for balance (creation of 1000 GDD) // flag for balance (creation of 1000 GDD)
addBalance?: boolean addBalance?: boolean
// balance // balance
balanceModified?: Date
recordDate?: Date recordDate?: Date
creationDate?: Date creationDate?: Date
amount?: number amount?: Decimal
creationTxHash?: Buffer
signature?: Buffer
} }

View File

@ -1,11 +1,11 @@
import { UserContext, ServerUserContext } from '../../interface/UserContext' import { UserContext, ServerUserContext } from '../../interface/UserContext'
import { BalanceContext, TransactionContext } from '../../interface/TransactionContext' import { TransactionContext } from '../../interface/TransactionContext'
import { UserInterface } from '../../interface/UserInterface' import { UserInterface } from '../../interface/UserInterface'
import { User } from '../../../entity/User' import { User } from '../../../entity/User'
import { ServerUser } from '../../../entity/ServerUser' import { ServerUser } from '../../../entity/ServerUser'
import { Balance } from '../../../entity/Balance'
import { Transaction } from '../../../entity/Transaction' import { Transaction } from '../../../entity/Transaction'
import { Factory } from 'typeorm-seeding' import { Factory } from 'typeorm-seeding'
import Decimal from 'decimal.js-light'
export const userSeeder = async (factory: Factory, userData: UserInterface): Promise<void> => { export const userSeeder = async (factory: Factory, userData: UserInterface): Promise<void> => {
const user = await factory(User)(createUserContext(userData)).create() const user = await factory(User)(createUserContext(userData)).create()
@ -16,7 +16,6 @@ export const userSeeder = async (factory: Factory, userData: UserInterface): Pro
if (userData.addBalance) { if (userData.addBalance) {
// create some GDD for the user // create some GDD for the user
await factory(Balance)(createBalanceContext(userData, user)).create()
await factory(Transaction)( await factory(Transaction)(
createTransactionContext(userData, user, 1, 'Herzlich Willkommen bei Gradido!'), createTransactionContext(userData, user, 1, 'Herzlich Willkommen bei Gradido!'),
).create() ).create()
@ -52,15 +51,6 @@ const createServerUserContext = (context: UserInterface): ServerUserContext => {
} }
} }
const createBalanceContext = (context: UserInterface, user: User): BalanceContext => {
return {
modified: context.balanceModified,
recordDate: context.recordDate,
amount: context.amount,
user,
}
}
const createTransactionContext = ( const createTransactionContext = (
context: UserInterface, context: UserInterface,
user: User, user: User,
@ -70,8 +60,8 @@ const createTransactionContext = (
return { return {
typeId: type, typeId: type,
userId: user.id, userId: user.id,
amount: BigInt(context.amount || 100000), amount: context.amount || new Decimal(1000),
balance: BigInt(context.amount || 100000), balance: context.amount || new Decimal(1000),
balanceDate: new Date(context.recordDate || Date.now()), balanceDate: new Date(context.recordDate || Date.now()),
memo, memo,
creationDate: context.creationDate, creationDate: context.creationDate,

View File

@ -1,3 +1,4 @@
import Decimal from 'decimal.js-light'
import { UserInterface } from '../../interface/UserInterface' import { UserInterface } from '../../interface/UserInterface'
export const bibiBloxberg: UserInterface = { export const bibiBloxberg: UserInterface = {
@ -19,16 +20,7 @@ export const bibiBloxberg: UserInterface = {
'knife normal level all hurdle crucial color avoid warrior stadium road bachelor affair topple hawk pottery right afford immune two ceiling budget glance hour ', 'knife normal level all hurdle crucial color avoid warrior stadium road bachelor affair topple hawk pottery right afford immune two ceiling budget glance hour ',
isAdmin: false, isAdmin: false,
addBalance: true, addBalance: true,
balanceModified: new Date('2021-11-30T10:37:11'),
recordDate: new Date('2021-11-30T10:37:11'), recordDate: new Date('2021-11-30T10:37:11'),
creationDate: new Date('2021-08-01 00:00:00'), creationDate: new Date('2021-08-01 00:00:00'),
amount: 10000000, amount: new Decimal(1000),
creationTxHash: Buffer.from(
'51103dc0fc2ca5d5d75a9557a1e899304e5406cfdb1328d8df6414d527b0118100000000000000000000000000000000',
'hex',
),
signature: Buffer.from(
'2a2c71f3e41adc060bbc3086577e2d57d24eeeb0a7727339c3f85aad813808f601d7e1df56a26e0929d2e67fc054fca429ccfa283ed2782185c7f009fe008f0c',
'hex',
),
} }

View File

@ -1,3 +1,4 @@
import Decimal from 'decimal.js-light'
import { UserInterface } from '../../interface/UserInterface' import { UserInterface } from '../../interface/UserInterface'
export const bobBaumeister: UserInterface = { export const bobBaumeister: UserInterface = {
@ -19,16 +20,7 @@ export const bobBaumeister: UserInterface = {
'detail master source effort unable waste tilt flush domain orchard art truck hint barrel response gate impose peanut secret merry three uncle wink resource ', 'detail master source effort unable waste tilt flush domain orchard art truck hint barrel response gate impose peanut secret merry three uncle wink resource ',
isAdmin: false, isAdmin: false,
addBalance: true, addBalance: true,
balanceModified: new Date('2021-11-30T10:37:14'),
recordDate: new Date('2021-11-30T10:37:14'), recordDate: new Date('2021-11-30T10:37:14'),
creationDate: new Date('2021-08-01 00:00:00'), creationDate: new Date('2021-08-01 00:00:00'),
amount: 10000000, amount: new Decimal(1000),
creationTxHash: Buffer.from(
'be095dc87acb94987e71168fee8ecbf50ecb43a180b1006e75d573b35725c69c00000000000000000000000000000000',
'hex',
),
signature: Buffer.from(
'1fbd6b9a3d359923b2501557f3bc79fa7e428127c8090fb16bc490b4d87870ab142b3817ddd902d22f0b26472a483233784a0e460c0622661752a13978903905',
'hex',
),
} }

View File

@ -1,3 +1,4 @@
import Decimal from 'decimal.js-light'
import { UserInterface } from '../../interface/UserInterface' import { UserInterface } from '../../interface/UserInterface'
export const raeuberHotzenplotz: UserInterface = { export const raeuberHotzenplotz: UserInterface = {
@ -19,16 +20,7 @@ export const raeuberHotzenplotz: UserInterface = {
'gospel trip tenant mouse spider skill auto curious man video chief response same little over expire drum display fancy clinic keen throw urge basket ', 'gospel trip tenant mouse spider skill auto curious man video chief response same little over expire drum display fancy clinic keen throw urge basket ',
isAdmin: false, isAdmin: false,
addBalance: true, addBalance: true,
balanceModified: new Date('2021-11-30T10:37:13'),
recordDate: new Date('2021-11-30T10:37:13'), recordDate: new Date('2021-11-30T10:37:13'),
creationDate: new Date('2021-08-01 00:00:00'), creationDate: new Date('2021-08-01 00:00:00'),
amount: 10000000, amount: new Decimal(1000),
creationTxHash: Buffer.from(
'23ba44fd84deb59b9f32969ad0cb18bfa4588be1bdb99c396888506474c16c1900000000000000000000000000000000',
'hex',
),
signature: Buffer.from(
'756d3da061687c575d1dbc5073908f646aa5f498b0927b217c83b48af471450e571dfe8421fb8e1f1ebd1104526b7e7c6fa78684e2da59c8f7f5a8dc3d9e5b0b',
'hex',
),
} }

View File

@ -0,0 +1,19 @@
import Decimal from 'decimal.js-light'
import { ValueTransformer } from 'typeorm'
Decimal.set({
precision: 25,
rounding: Decimal.ROUND_HALF_UP,
})
export const DecimalTransformer: ValueTransformer = {
/**
* Used to marshal Decimal when writing to the database.
*/
to: (decimal: Decimal | null): string | null => (decimal ? decimal.toString() : null),
/**
* Used to unmarshal Decimal when reading from the database.
*/
from: (decimal: string | null): Decimal | null => (decimal ? new Decimal(decimal) : null),
}

View File

@ -560,6 +560,11 @@ decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
decimal.js-light@^2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
deep-is@^0.1.3: deep-is@^0.1.3:
version "0.1.3" version "0.1.3"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"

View File

@ -57,7 +57,7 @@ export const createUser = gql`
` `
export const sendCoins = gql` export const sendCoins = gql`
mutation($email: String!, $amount: Float!, $memo: String!) { mutation($email: String!, $amount: Decimal!, $memo: String!) {
sendCoins(email: $email, amount: $amount, memo: $memo) sendCoins(email: $email, amount: $amount, memo: $memo)
} }
` `

View File

@ -55,30 +55,26 @@ export const transactionsQuery = gql`
order: $order order: $order
onlyCreations: $onlyCreations onlyCreations: $onlyCreations
) { ) {
gdtSum balanceGDT
count count
balance balance
decay decayStartBlock
decayDate
transactions { transactions {
type id
balance typeId
decayStart amount
decayEnd balanceDate
decayDuration
memo memo
transactionId linkedUser {
name firstName
email lastName
date }
decay { decay {
balance decay
decayStart start
decayEnd end
decayDuration duration
decayStartBlock
} }
firstTransaction
} }
} }
} }