mirror of
https://github.com/IT4Change/gradido.git
synced 2026-02-06 09:56:05 +00:00
Merge branch 'master' into dlt_connector_direct_usage
This commit is contained in:
commit
be62b62c6a
@ -15,7 +15,11 @@
|
||||
</div>
|
||||
<div ref="chatContainer" class="messages-scroll-container">
|
||||
<TransitionGroup class="messages" tag="div" name="chat">
|
||||
<div v-for="(message, index) in messages" :key="index" :class="['message', message.role]">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
:class="['message', message.role, { 'message-error': message.isError }]"
|
||||
>
|
||||
<div class="message-content position-relative inner-container">
|
||||
<span v-html="formatMessage(message)"></span>
|
||||
<b-button
|
||||
@ -25,7 +29,7 @@
|
||||
:title="$t('copy-to-clipboard')"
|
||||
@click="copyToClipboard(message.content)"
|
||||
>
|
||||
<IBiClipboard></IBiClipboard>
|
||||
<IBiCopy></IBiCopy>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
@ -170,7 +174,18 @@ const sendMessage = () => {
|
||||
onMounted(async () => {
|
||||
if (messages.value.length === 0) {
|
||||
loading.value = true
|
||||
await resumeChatRefetch()
|
||||
try {
|
||||
await resumeChatRefetch()
|
||||
} catch (error) {
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
toastError(`Error loading chat: ${error.graphQLErrors[0].message}`)
|
||||
return
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(error, null, 2))
|
||||
toastError(`Error loading chat: ${error}`)
|
||||
}
|
||||
}
|
||||
const messagesFromServer = resumeChatResult.value.resumeChat
|
||||
if (messagesFromServer && messagesFromServer.length > 0) {
|
||||
threadId.value = messagesFromServer[0].threadId
|
||||
@ -279,6 +294,17 @@ onMounted(async () => {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message.error .message-content {
|
||||
background-color: #f1e5e5;
|
||||
color: rgb(194 12 12);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
|
||||
@ -26,6 +26,7 @@ fragment AiChatMessageFields on ChatGptMessage {
|
||||
content
|
||||
role
|
||||
threadId
|
||||
isError
|
||||
}
|
||||
|
||||
fragment UserCommonFields on User {
|
||||
|
||||
@ -11,6 +11,8 @@ import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
|
||||
import { getLogger } from 'log4js'
|
||||
|
||||
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.apis.openai.OpenaiClient`)
|
||||
// this is the time after when openai is deleting an inactive thread
|
||||
const OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS = 60
|
||||
|
||||
/**
|
||||
* The `OpenaiClient` class is a singleton that provides an interface to interact with the OpenAI API.
|
||||
@ -87,21 +89,35 @@ export class OpenaiClient {
|
||||
logger.warn(`No openai thread found for user: ${user.id}`)
|
||||
return []
|
||||
}
|
||||
const threadMessages = (
|
||||
await this.openai.beta.threads.messages.list(openaiThreadEntity.id, { order: 'desc' })
|
||||
).getPaginatedItems()
|
||||
if (openaiThreadEntity.updatedAt < new Date(Date.now() - OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS * 24 * 60 * 60 * 1000)) {
|
||||
logger.info(`Openai thread for user: ${user.id} is older than ${OPENAI_AI_THREAD_DEFAULT_TIMEOUT_DAYS} days, deleting...`)
|
||||
// let run async, because it could need some time, but we don't need to wait, because we create a new one nevertheless
|
||||
void this.deleteThread(openaiThreadEntity.id)
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const threadMessages = (
|
||||
await this.openai.beta.threads.messages.list(openaiThreadEntity.id, { order: 'desc' })
|
||||
).getPaginatedItems()
|
||||
|
||||
logger.info(`Resumed thread: ${openaiThreadEntity.id}`)
|
||||
return threadMessages
|
||||
.map(
|
||||
(message) =>
|
||||
new MessageModel(
|
||||
this.messageContentToString(message),
|
||||
message.role,
|
||||
openaiThreadEntity.id,
|
||||
),
|
||||
)
|
||||
.reverse()
|
||||
logger.info(`Resumed thread: ${openaiThreadEntity.id}`)
|
||||
return threadMessages
|
||||
.map(
|
||||
(message) =>
|
||||
new MessageModel(
|
||||
this.messageContentToString(message),
|
||||
message.role,
|
||||
openaiThreadEntity.id,
|
||||
),
|
||||
)
|
||||
.reverse()
|
||||
} catch (e) {
|
||||
if(e instanceof Error && e.toString().includes('No thread found with id')) {
|
||||
logger.info(`Thread not found: ${openaiThreadEntity.id}`)
|
||||
return []
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteThread(threadId: string): Promise<boolean> {
|
||||
@ -124,6 +140,7 @@ export class OpenaiClient {
|
||||
}
|
||||
|
||||
public async runAndGetLastNewMessage(threadId: string): Promise<MessageModel> {
|
||||
const updateOpenAiThreadResolver = OpenaiThreads.update({ id: threadId }, { updatedAt: new Date() })
|
||||
const run = await this.openai.beta.threads.runs.createAndPoll(threadId, {
|
||||
assistant_id: CONFIG.OPENAI_ASSISTANT_ID,
|
||||
})
|
||||
@ -138,6 +155,7 @@ export class OpenaiClient {
|
||||
logger.warn(`No message in thread: ${threadId}, run: ${run.id}`, messagesPage.data)
|
||||
return new MessageModel('No Answer', 'assistant')
|
||||
}
|
||||
await updateOpenAiThreadResolver
|
||||
return new MessageModel(this.messageContentToString(message), 'assistant')
|
||||
}
|
||||
|
||||
|
||||
@ -35,7 +35,6 @@ export enum RIGHTS {
|
||||
UPDATE_CONTRIBUTION = 'UPDATE_CONTRIBUTION',
|
||||
LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS',
|
||||
COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS',
|
||||
COMMUNITY_STATUS = 'COMMUNITY_STATUS',
|
||||
SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS',
|
||||
CREATE_CONTRIBUTION_MESSAGE = 'CREATE_CONTRIBUTION_MESSAGE',
|
||||
LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES',
|
||||
|
||||
@ -26,7 +26,6 @@ export const USER_RIGHTS = [
|
||||
RIGHTS.SEARCH_ADMIN_USERS,
|
||||
RIGHTS.LIST_CONTRIBUTION_LINKS,
|
||||
RIGHTS.COMMUNITY_STATISTICS,
|
||||
RIGHTS.COMMUNITY_STATUS,
|
||||
RIGHTS.CREATE_CONTRIBUTION_MESSAGE,
|
||||
RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES,
|
||||
RIGHTS.OPEN_CREATIONS,
|
||||
|
||||
@ -4,7 +4,7 @@ import { FederationClient as V1_0_FederationClient } from '@/federation/client/1
|
||||
import { FederationClient as V1_1_FederationClient } from '@/federation/client/1_1/FederationClient'
|
||||
import { ApiVersionType, ensureUrlEndsWithSlash } from 'core'
|
||||
|
||||
export type FederationClient = V1_0_FederationClient | V1_1_FederationClient
|
||||
type FederationClient = V1_0_FederationClient | V1_1_FederationClient
|
||||
|
||||
interface FederationClientInstance {
|
||||
id: number
|
||||
|
||||
@ -10,10 +10,14 @@ export class ChatGptMessage {
|
||||
@Field()
|
||||
role: string
|
||||
|
||||
@Field()
|
||||
threadId: string
|
||||
@Field({ nullable: true })
|
||||
threadId?: string
|
||||
|
||||
public constructor(data: Partial<Message>) {
|
||||
@Field()
|
||||
isError: boolean
|
||||
|
||||
public constructor(data: Partial<Message>, isError: boolean = false) {
|
||||
Object.assign(this, data)
|
||||
this.isError = isError
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ export class Community {
|
||||
this.creationDate = dbCom.creationDate
|
||||
this.uuid = dbCom.communityUuid
|
||||
this.authenticatedAt = dbCom.authenticatedAt
|
||||
// this.gmsApiKey = dbCom.gmsApiKey //
|
||||
this.hieroTopicId = dbCom.hieroTopicId
|
||||
}
|
||||
|
||||
@ -40,10 +39,6 @@ export class Community {
|
||||
@Field(() => Date, { nullable: true })
|
||||
authenticatedAt: Date | null
|
||||
|
||||
// gms api key should only seen by admins, they can use AdminCommunityView
|
||||
// @Field(() => String, { nullable: true })
|
||||
// gmsApiKey: string | null
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hieroTopicId: string | null
|
||||
}
|
||||
|
||||
@ -15,10 +15,10 @@ export class AiChatResolver {
|
||||
async resumeChat(@Ctx() context: Context): Promise<ChatGptMessage[]> {
|
||||
const openaiClient = OpenaiClient.getInstance()
|
||||
if (!openaiClient) {
|
||||
return Promise.resolve([new ChatGptMessage({ content: 'OpenAI API is not enabled' })])
|
||||
return Promise.resolve([new ChatGptMessage({ content: 'OpenAI API is not enabled', role: 'assistant' }, true)])
|
||||
}
|
||||
if (!context.user) {
|
||||
return Promise.resolve([new ChatGptMessage({ content: 'User not found' })])
|
||||
return Promise.resolve([new ChatGptMessage({ content: 'User not found', role: 'assistant' }, true)])
|
||||
}
|
||||
const messages = await openaiClient.resumeThread(context.user)
|
||||
return messages.map((message) => new ChatGptMessage(message))
|
||||
@ -42,10 +42,10 @@ export class AiChatResolver {
|
||||
): Promise<ChatGptMessage> {
|
||||
const openaiClient = OpenaiClient.getInstance()
|
||||
if (!openaiClient) {
|
||||
return Promise.resolve(new ChatGptMessage({ content: 'OpenAI API is not enabled' }))
|
||||
return Promise.resolve(new ChatGptMessage({ content: 'OpenAI API is not enabled', role: 'assistant' }, true))
|
||||
}
|
||||
if (!context.user) {
|
||||
return Promise.resolve(new ChatGptMessage({ content: 'User not found' }))
|
||||
return Promise.resolve(new ChatGptMessage({ content: 'User not found', role: 'assistant' }, true))
|
||||
}
|
||||
const messageObj = new Message(message)
|
||||
if (!threadId || threadId.length === 0) {
|
||||
|
||||
@ -4,7 +4,6 @@ import {
|
||||
getHomeCommunity
|
||||
} from 'database'
|
||||
import { Arg, Args, Authorized, Mutation, Query, Resolver } from 'type-graphql'
|
||||
|
||||
import { Paginated } from '@arg/Paginated'
|
||||
import { EditCommunityInput } from '@input/EditCommunityInput'
|
||||
import { AdminCommunityView } from '@model/AdminCommunityView'
|
||||
@ -19,6 +18,7 @@ import {
|
||||
getCommunityByIdentifier,
|
||||
getCommunityByUuid,
|
||||
} from './util/communities'
|
||||
import { updateAllDefinedAndChanged } from 'shared'
|
||||
|
||||
import { CONFIG } from '@/config'
|
||||
|
||||
@ -78,24 +78,22 @@ export class CommunityResolver {
|
||||
if (homeCom.foreign) {
|
||||
throw new LogError('Error: Only the HomeCommunity could be modified!')
|
||||
}
|
||||
|
||||
if (
|
||||
homeCom.gmsApiKey !== gmsApiKey ||
|
||||
homeCom.location !== location ||
|
||||
homeCom.hieroTopicId !== hieroTopicId
|
||||
) {
|
||||
// TODO: think about this, it is really expected to delete gmsApiKey if no new one is given?
|
||||
homeCom.gmsApiKey = gmsApiKey ?? null
|
||||
if (location) {
|
||||
homeCom.location = Location2Point(location)
|
||||
}
|
||||
// update only with new value, don't overwrite existing value with null or undefined!
|
||||
if (hieroTopicId) {
|
||||
homeCom.hieroTopicId = hieroTopicId
|
||||
let updated = false
|
||||
// if location is undefined, it should not be changed
|
||||
// if location is null, it should be set to null
|
||||
if (typeof location !== 'undefined') {
|
||||
const newLocation = location ? Location2Point(location) : null
|
||||
if (newLocation !== homeCom.location) {
|
||||
homeCom.location = newLocation
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if (updateAllDefinedAndChanged(homeCom, { gmsApiKey, hieroTopicId })) {
|
||||
updated = true
|
||||
}
|
||||
if (updated) {
|
||||
await DbCommunity.save(homeCom)
|
||||
}
|
||||
|
||||
return new AdminCommunityView(homeCom)
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,8 @@ import { QueryLinkResult } from '@union/QueryLinkResult'
|
||||
import { Decay, interpretEncryptedTransferArgs, TransactionTypeId } from 'core'
|
||||
import {
|
||||
AppDatabase, Contribution as DbContribution,
|
||||
ContributionLink as DbContributionLink, FederatedCommunity as DbFederatedCommunity,
|
||||
ContributionLink as DbContributionLink,
|
||||
FederatedCommunity as DbFederatedCommunity,
|
||||
DltTransaction as DbDltTransaction,
|
||||
Transaction as DbTransaction,
|
||||
TransactionLink as DbTransactionLink,
|
||||
@ -39,8 +40,16 @@ import { Context, getClientTimezoneOffset, getUser } from '@/server/context'
|
||||
import { calculateBalance } from '@/util/validate'
|
||||
import { fullName } from 'core'
|
||||
import { TRANSACTION_LINK_LOCK, TRANSACTIONS_LOCK } from 'database'
|
||||
import { calculateDecay, decode, DisburseJwtPayloadType, encode, encryptAndSign, RedeemJwtPayloadType, verify } from 'shared'
|
||||
|
||||
import {
|
||||
calculateDecay,
|
||||
compoundInterest,
|
||||
decode,
|
||||
DisburseJwtPayloadType,
|
||||
encode,
|
||||
encryptAndSign,
|
||||
RedeemJwtPayloadType,
|
||||
verify
|
||||
} from 'shared'
|
||||
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
|
||||
import { DisbursementClient as V1_0_DisbursementClient } from '@/federation/client/1_0/DisbursementClient'
|
||||
import { DisbursementClientFactory } from '@/federation/client/DisbursementClientFactory'
|
||||
@ -93,7 +102,7 @@ export class TransactionLinkResolver {
|
||||
const createdDate = new Date()
|
||||
const validUntil = transactionLinkExpireDate(createdDate)
|
||||
|
||||
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
|
||||
const holdAvailableAmount = compoundInterest(amount, CODE_VALID_DAYS_DURATION * 24 * 60 * 60)
|
||||
|
||||
// validate amount
|
||||
const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate)
|
||||
|
||||
@ -464,7 +464,7 @@ export class TransactionResolver {
|
||||
recipientCommunityIdentifier,
|
||||
)
|
||||
if (!recipientUser) {
|
||||
throw new LogError('The recipient user was not found', { recipientIdentifier, recipientCommunityIdentifier })
|
||||
throw new LogError('The recipient user was not found', recipientUser)
|
||||
}
|
||||
logger.addContext('to', recipientUser?.id)
|
||||
if (recipientUser.foreign) {
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
Root,
|
||||
} from 'type-graphql'
|
||||
import { IRestResponse } from 'typed-rest-client'
|
||||
import { EntityNotFoundError, In, Point } from 'typeorm'
|
||||
import { EntityManager, EntityNotFoundError, In, Point } from 'typeorm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { UserArgs } from '@arg//UserArgs'
|
||||
@ -104,7 +104,11 @@ import { deleteUserRole, setUserRole } from './util/modifyUserRole'
|
||||
import { sendUserToGms } from './util/sendUserToGms'
|
||||
import { syncHumhub } from './util/syncHumhub'
|
||||
import { validateAlias } from 'core'
|
||||
<<<<<<< HEAD
|
||||
import { registerAddressTransaction } from '@/apis/dltConnector'
|
||||
=======
|
||||
import { updateAllDefinedAndChanged } from 'shared'
|
||||
>>>>>>> master
|
||||
|
||||
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
|
||||
const DEFAULT_LANGUAGE = 'de'
|
||||
@ -739,18 +743,22 @@ export class UserResolver {
|
||||
user.humhubPublishName as PublishNameType,
|
||||
)
|
||||
|
||||
// try {
|
||||
if (firstName) {
|
||||
user.firstName = firstName
|
||||
}
|
||||
|
||||
if (lastName) {
|
||||
user.lastName = lastName
|
||||
}
|
||||
let updated = updateAllDefinedAndChanged(user, {
|
||||
firstName,
|
||||
lastName,
|
||||
hideAmountGDD,
|
||||
hideAmountGDT,
|
||||
humhubAllowed,
|
||||
gmsAllowed,
|
||||
gmsPublishName: gmsPublishName?.valueOf(),
|
||||
humhubPublishName: humhubPublishName?.valueOf(),
|
||||
gmsPublishLocation: gmsPublishLocation?.valueOf(),
|
||||
})
|
||||
|
||||
// currently alias can only be set, not updated
|
||||
if (alias && !user.alias && (await validateAlias(alias))) {
|
||||
user.alias = alias
|
||||
updated = true
|
||||
}
|
||||
|
||||
if (language) {
|
||||
@ -760,6 +768,7 @@ export class UserResolver {
|
||||
}
|
||||
user.language = language
|
||||
i18n.setLocale(language)
|
||||
updated = true
|
||||
}
|
||||
|
||||
if (password && passwordNew) {
|
||||
@ -780,55 +789,28 @@ export class UserResolver {
|
||||
// Save new password hash and newly encrypted private key
|
||||
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
|
||||
user.password = await encryptPassword(user, passwordNew)
|
||||
updated = true
|
||||
}
|
||||
|
||||
// Save hideAmountGDD value
|
||||
if (hideAmountGDD !== undefined) {
|
||||
user.hideAmountGDD = hideAmountGDD
|
||||
}
|
||||
// Save hideAmountGDT value
|
||||
if (hideAmountGDT !== undefined) {
|
||||
user.hideAmountGDT = hideAmountGDT
|
||||
}
|
||||
if (humhubAllowed !== undefined) {
|
||||
user.humhubAllowed = humhubAllowed
|
||||
}
|
||||
if (gmsAllowed !== undefined) {
|
||||
user.gmsAllowed = gmsAllowed
|
||||
}
|
||||
if (gmsPublishName !== null && gmsPublishName !== undefined) {
|
||||
user.gmsPublishName = gmsPublishName
|
||||
}
|
||||
if (humhubPublishName !== null && humhubPublishName !== undefined) {
|
||||
user.humhubPublishName = humhubPublishName
|
||||
}
|
||||
if (gmsLocation) {
|
||||
user.location = Location2Point(gmsLocation)
|
||||
updated = true
|
||||
}
|
||||
if (gmsPublishLocation !== null && gmsPublishLocation !== undefined) {
|
||||
user.gmsPublishLocation = gmsPublishLocation
|
||||
|
||||
// early exit if no update was made
|
||||
if (!updated) {
|
||||
return true
|
||||
}
|
||||
// } catch (err) {
|
||||
// console.log('error:', err)
|
||||
// }
|
||||
const queryRunner = db.getDataSource().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
await queryRunner.startTransaction('REPEATABLE READ')
|
||||
|
||||
try {
|
||||
await queryRunner.manager.save(user).catch((error) => {
|
||||
throw new LogError('Error saving user', error)
|
||||
})
|
||||
|
||||
await queryRunner.commitTransaction()
|
||||
logger.debug('writing User data successful...', new UserLoggingView(user))
|
||||
} catch (e) {
|
||||
await queryRunner.rollbackTransaction()
|
||||
throw new LogError('Error on writing updated user data', e)
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
await DbUser.save(user)
|
||||
} catch (error) {
|
||||
const errorMessage = 'Error saving user'
|
||||
logger.error(errorMessage, error)
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
logger.info('updateUserInfos() successfully finished...')
|
||||
logger.debug('writing User data successful...', new UserLoggingView(user))
|
||||
await EVENT_USER_INFO_UPDATE(user)
|
||||
|
||||
// validate if user settings are changed with relevance to update gms-user
|
||||
|
||||
@ -39,13 +39,11 @@ export const testEnvironment = async (testLogger = getLogger('apollo'), testI18n
|
||||
}
|
||||
|
||||
export const resetEntity = async (entity: any) => {
|
||||
// delete data and reset autoincrement!
|
||||
await entity.clear()
|
||||
/*const items = await entity.find({ withDeleted: true })
|
||||
const items = await entity.find({ withDeleted: true })
|
||||
if (items.length > 0) {
|
||||
const ids = items.map((e: any) => e.id)
|
||||
await entity.delete(ids)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
export const resetToken = () => {
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
|
||||
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn(
|
||||
'ALTER TABLE `openai_threads` ADD COLUMN `updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP AFTER `createdAt`;'
|
||||
)
|
||||
}
|
||||
|
||||
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn('ALTER TABLE `openai_threads` DROP COLUMN `updatedAt`;')
|
||||
}
|
||||
@ -1,13 +1,16 @@
|
||||
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'
|
||||
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'
|
||||
|
||||
@Entity('openai_threads')
|
||||
export class OpenaiThreads extends BaseEntity {
|
||||
@PrimaryColumn({ type: 'char', length: 30 })
|
||||
id: string
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
createdAt: Date
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
updatedAt: Date
|
||||
|
||||
@Column({ name: 'user_id', type: 'int', unsigned: true })
|
||||
userId: number
|
||||
}
|
||||
|
||||
@ -23,4 +23,10 @@ export default {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
@media screen and (width <= 450px) {
|
||||
.page-breadcrumb {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -20,20 +20,29 @@
|
||||
<BCol>{{ $t('advanced-calculation') }}</BCol>
|
||||
</BRow>
|
||||
<BRow class="pe-3" offset="2">
|
||||
<BCol offset="2">{{ $t('form.current_balance') }}</BCol>
|
||||
<BCol offset="2">{{ $t('form.current_available') }}</BCol>
|
||||
<BCol>{{ $filters.GDD(balance) }}</BCol>
|
||||
</BRow>
|
||||
<BRow class="pe-3">
|
||||
<BCol offset="2">
|
||||
<strong>{{ $t('form.your_amount') }}</strong>
|
||||
<strong>{{ $t('form.link_amount') }}</strong>
|
||||
</BCol>
|
||||
<BCol class="borderbottom">
|
||||
<BCol>
|
||||
<strong>{{ $filters.GDD(amount * -1) }}</strong>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<BRow class="pe-3">
|
||||
<BCol offset="2">{{ $t('form.new_balance') }}</BCol>
|
||||
<BCol>{{ $filters.GDD(balance - amount) }}</BCol>
|
||||
<BCol offset="2">{{ $t('decay.decay') }}</BCol>
|
||||
<BCol class="borderbottom">{{ $filters.GDD(amount - blockedAmount) }}</BCol>
|
||||
</BRow>
|
||||
<BRow class="pe-3">
|
||||
<BCol offset="2">{{ $t('form.available_after') }}</BCol>
|
||||
<BCol>{{ $filters.GDD(balance - blockedAmount) }}</BCol>
|
||||
</BRow>
|
||||
<BRow class="pe-6 mt-2">
|
||||
<BCol offset="1">
|
||||
<small>{{ $t('form.link_decay_description') }}</small>
|
||||
</BCol>
|
||||
</BRow>
|
||||
<BRow class="mt-5">
|
||||
<BCol cols="12" md="6" lg="6">
|
||||
@ -57,6 +66,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { LINK_COMPOUND_INTEREST_FACTOR } from '@/constants'
|
||||
export default {
|
||||
name: 'TransactionConfirmationLink',
|
||||
props: {
|
||||
@ -68,7 +78,13 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
totalBalance() {
|
||||
return this.balance - this.amount * 1.028
|
||||
return this.balance - this.blockedAmount
|
||||
},
|
||||
blockedAmount() {
|
||||
// correct formula
|
||||
return this.amount * LINK_COMPOUND_INTEREST_FACTOR
|
||||
// same formula as in backend
|
||||
// return 2 * this.amount - this.amount * Math.pow(0.99999997803504048, 1209600)
|
||||
},
|
||||
disabled() {
|
||||
if (this.totalBalance < 0) {
|
||||
|
||||
@ -222,7 +222,6 @@ const validationSchema = computed(() => {
|
||||
return object({
|
||||
memo: memoSchema,
|
||||
amount: amountSchema,
|
||||
// todo: found a better way, because this validation test has side effects
|
||||
identifier: identifierSchema.test(
|
||||
'community-is-reachable',
|
||||
'form.validation.identifier.communityIsReachable',
|
||||
|
||||
@ -3,12 +3,14 @@
|
||||
<div class="navbar-element">
|
||||
<BNavbar toggleable="lg" class="pe-4">
|
||||
<BNavbarBrand>
|
||||
<BImg
|
||||
class="mt-lg--2 mt-3 mb-3 d-none d-lg-block zindex10"
|
||||
:src="logo"
|
||||
width="200"
|
||||
alt="Logo"
|
||||
/>
|
||||
<router-link to="/overview">
|
||||
<BImg
|
||||
class="mt-lg--2 mt-3 mb-3 d-none d-lg-block zindex10"
|
||||
:src="logo"
|
||||
width="200"
|
||||
alt="Logo"
|
||||
/>
|
||||
</router-link>
|
||||
<div v-b-toggle.sidebar-mobile variant="link" class="d-block d-lg-none">
|
||||
<span class="navbar-toggler-icon h2"></span>
|
||||
</div>
|
||||
@ -17,9 +19,9 @@
|
||||
<BImg class="sheet-img position-absolute zindex-1" :src="sheet"></BImg>
|
||||
|
||||
<BNavbarNav class="ms-auto" right>
|
||||
<div class="align-items-center">
|
||||
<router-link to="/settings">
|
||||
<div class="d-flex me-3">
|
||||
<div class="">
|
||||
<router-link to="/settings" class="d-flex flex-column align-items-end text-end">
|
||||
<div class="ms-auto">
|
||||
<app-avatar
|
||||
class="vue3-avatar"
|
||||
:name="username.username"
|
||||
@ -29,19 +31,33 @@
|
||||
:size="61"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div data-test="navbar-item-username">{{ username.username }}</div>
|
||||
<div v-if="!hasUsername">
|
||||
<div class="mt-3" data-test="navbar-item-username">{{ username.username }}</div>
|
||||
<div class="small mt-1" data-test="navbar-item-gradido-id">{{ gradidoId }}</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<div class="small navbar-like-link" data-test="navbar-item-gradido-id">
|
||||
{{ gradidoId }}
|
||||
<a
|
||||
class="copy-clipboard-button"
|
||||
:title="$t('copy-to-clipboard')"
|
||||
@click="copyToClipboard(gradidoId)"
|
||||
<div class="d-flex flex-column align-items-end text-end">
|
||||
<div
|
||||
v-if="hasUsername"
|
||||
class="navbar-like-link mt-3"
|
||||
data-test="navbar-item-username"
|
||||
>
|
||||
<IBiClipboard></IBiClipboard>
|
||||
</a>
|
||||
{{ username.username }}
|
||||
</div>
|
||||
<div
|
||||
v-if="hasUsername"
|
||||
class="small navbar-like-link pointer mt-1"
|
||||
data-test="navbar-item-gradido-id"
|
||||
>
|
||||
<a
|
||||
class="copy-clipboard-button"
|
||||
:title="$t('copy-to-clipboard')"
|
||||
@click="copyToClipboard(gradidoId)"
|
||||
>
|
||||
<IBiCopy></IBiCopy>
|
||||
{{ gradidoId }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BNavbarNav>
|
||||
@ -83,6 +99,9 @@ export default {
|
||||
initials: `${this.$store.state.firstName[0]}${this.$store.state.lastName[0]}`,
|
||||
}
|
||||
},
|
||||
hasUsername() {
|
||||
return this.$store.state.username && this.$store.state.username.length > 0
|
||||
},
|
||||
gradidoId() {
|
||||
const name = this.$store.state.username
|
||||
? this.$store.state.username
|
||||
|
||||
@ -94,7 +94,8 @@ onResult(({ data }) => {
|
||||
onError(() => {
|
||||
isUserSearchDisabled.value = true
|
||||
if (gmsAllowed.value && gmsUserLocationExists.value) {
|
||||
toastError('authenticateGmsUserSearch failed!')
|
||||
// setting isUserSearchDisabled.value to true will show that GMS is offline, no need to further post to the user
|
||||
// toastError('authenticateGmsUserSearch failed!')
|
||||
} else if (gmsAllowed.value && !gmsUserLocationExists.value) {
|
||||
// toastError('capture your location first!')
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@ -1,2 +1,5 @@
|
||||
export const GDD_PER_HOUR = 20
|
||||
export const PAGE_SIZE = 25
|
||||
// compound interest factor (decay reversed) for 14 days (hard coded backend link timeout)
|
||||
// 365.2425 days per year (gregorian calendar year)
|
||||
export const LINK_COMPOUND_INTEREST_FACTOR = Math.pow(2, 14 / 365.2425)
|
||||
|
||||
@ -114,6 +114,7 @@
|
||||
"thanksYouWith": "dankt dir mit"
|
||||
},
|
||||
"contributionText": "Beitragstext",
|
||||
"copy-to-clipboard": "In Zwischenablage kopieren",
|
||||
"creation": "Schöpfen",
|
||||
"decay": {
|
||||
"before_startblock_transaction": "Diese Transaktion beinhaltet keine Vergänglichkeit.",
|
||||
@ -164,10 +165,12 @@
|
||||
"form": {
|
||||
"amount": "Betrag",
|
||||
"at": "am",
|
||||
"available_after": "Verfügbar nach Bestätigung",
|
||||
"cancel": "Abbrechen",
|
||||
"change": "Ändern",
|
||||
"check_now": "Jetzt prüfen",
|
||||
"close": "Schließen",
|
||||
"current_available": "Aktuell verfügbar",
|
||||
"current_balance": "Aktueller Kontostand",
|
||||
"date": "Datum",
|
||||
"description": "Beschreibung",
|
||||
@ -178,6 +181,8 @@
|
||||
"hours": "Stunden",
|
||||
"identifier": "Email, Nutzername oder Gradido ID",
|
||||
"lastname": "Nachname",
|
||||
"link_amount": "Link-Betrag",
|
||||
"link_decay_description": "Der Link-Betrag wird zusammen mit der maximalen Vergänglichkeit blockiert. Nach Einlösen, Verfallen oder Löschen des Links wird der Rest wieder freigegeben.",
|
||||
"memo": "Nachricht",
|
||||
"message": "Nachricht",
|
||||
"new_balance": "Neuer Kontostand nach Bestätigung",
|
||||
|
||||
@ -114,6 +114,7 @@
|
||||
"thanksYouWith": "thanks you with"
|
||||
},
|
||||
"contributionText": "Contribution Text",
|
||||
"copy-to-clipboard": "Copy to clipboard",
|
||||
"creation": "Creation",
|
||||
"decay": {
|
||||
"before_startblock_transaction": "This transaction does not include decay.",
|
||||
@ -164,10 +165,12 @@
|
||||
"form": {
|
||||
"amount": "Amount",
|
||||
"at": "at",
|
||||
"available_after": "Available after confirmation",
|
||||
"cancel": "Cancel",
|
||||
"change": "Change",
|
||||
"check_now": "Check now",
|
||||
"close": "Close",
|
||||
"current_available": "Currently available",
|
||||
"current_balance": "Current Balance",
|
||||
"date": "Date",
|
||||
"description": "Description",
|
||||
@ -178,6 +181,8 @@
|
||||
"hours": "Hours",
|
||||
"identifier": "Email, username or gradido ID",
|
||||
"lastname": "Lastname",
|
||||
"link_amount": "Link amount",
|
||||
"link_decay_description": "The link amount is blocked along with the maximum decay. After the link has been redeemed, expired, or deleted, the rest is released again.",
|
||||
"memo": "Message",
|
||||
"message": "Message",
|
||||
"new_balance": "Account balance after confirmation",
|
||||
|
||||
@ -101,6 +101,7 @@
|
||||
"thanksYouWith": "te agradece con",
|
||||
"unique": "(único)"
|
||||
},
|
||||
"copy-to-clipboard": "Copiar al portapapeles",
|
||||
"decay": {
|
||||
"before_startblock_transaction": "Esta transacción no implica disminución en su valor.",
|
||||
"calculation_decay": "Cálculo de la disminución gradual del valor",
|
||||
@ -146,10 +147,12 @@
|
||||
"form": {
|
||||
"amount": "Importe",
|
||||
"at": "am",
|
||||
"available_after": "Disponible tras la confirmación",
|
||||
"cancel": "Cancelar",
|
||||
"change": "Cambiar",
|
||||
"check_now": "Revisar",
|
||||
"close": "Cerrar",
|
||||
"current_available": "Actualmente disponible",
|
||||
"current_balance": "Saldo de cuenta actual",
|
||||
"date": "Fecha",
|
||||
"description": "Descripción",
|
||||
@ -158,6 +161,8 @@
|
||||
"from": "De",
|
||||
"generate_now": "crear ahora",
|
||||
"lastname": "Apellido",
|
||||
"link_amount": "Importe del enlace",
|
||||
"link_decay_description": "El importe del enlace se bloquea junto con la ransience máxima. Una vez que el enlace se ha canjeado, caducado o eliminado, el resto se libera de nuevo.",
|
||||
"memo": "Mensaje",
|
||||
"message": "Noticia",
|
||||
"new_balance": "Saldo de cuenta nuevo depués de confirmación",
|
||||
|
||||
@ -103,6 +103,7 @@
|
||||
"unique": "(unique)"
|
||||
},
|
||||
"contributionText": "Texte de la contribution",
|
||||
"copy-to-clipboard": "Copier dans le presse-papier",
|
||||
"creation": "Création",
|
||||
"decay": {
|
||||
"before_startblock_transaction": "Cette transaction n'est pas péremptoire.",
|
||||
@ -151,10 +152,12 @@
|
||||
"form": {
|
||||
"amount": "Montant",
|
||||
"at": "à",
|
||||
"available_after": "Disponible après confirmation",
|
||||
"cancel": "Annuler",
|
||||
"change": "Changer",
|
||||
"check_now": "Vérifier maintenant",
|
||||
"close": "Fermer",
|
||||
"current_available": "Actuellement disponible",
|
||||
"current_balance": "Solde actuel",
|
||||
"date": "Date",
|
||||
"description": "Description",
|
||||
@ -164,6 +167,8 @@
|
||||
"generate_now": "Produire maintenant",
|
||||
"hours": "Heures",
|
||||
"lastname": "Nom",
|
||||
"link_amount": "Montant du lien",
|
||||
"link_decay_description": "Le montant du lien est bloqué avec le dépérissement maximale. Une fois le lien utilisé, expiré ou supprimé, le reste est à nouveau débloqué.",
|
||||
"memo": "Note",
|
||||
"message": "Message",
|
||||
"new_balance": "Montant du solde après confirmation",
|
||||
@ -192,7 +197,7 @@
|
||||
"gddCreationTime": "Le champ {_field_} doit comprendre un nombre entre {min} et {max} avec un maximum de une décimale.",
|
||||
"gddSendAmount": "Le champ {_field_} doit comprendre un nombre entre {min} et {max} avec un maximum de deux chiffres après la virgule",
|
||||
"identifier": {
|
||||
"communityIsReachable": "Communauté non trouvée ou non joignable!",
|
||||
"communityIsReachable": "Communauté non joignable!",
|
||||
"required": "Le destinataire est un champ obligatoire.",
|
||||
"typeError": "Le destinataire doit être un email, un nom d'utilisateur ou un Gradido ID."
|
||||
},
|
||||
|
||||
@ -101,6 +101,7 @@
|
||||
"thanksYouWith": "bedankt jou met",
|
||||
"unique": "(uniek)"
|
||||
},
|
||||
"copy-to-clipboard": "Kopieer naar klembord",
|
||||
"decay": {
|
||||
"before_startblock_transaction": "Deze transactie heeft geen vergankelijkheid.",
|
||||
"calculation_decay": "Berekening van de vergankelijkheid",
|
||||
@ -146,10 +147,12 @@
|
||||
"form": {
|
||||
"amount": "Bedrag",
|
||||
"at": "op",
|
||||
"available_after": "Beschikbaar na bevestiging",
|
||||
"cancel": "Annuleren",
|
||||
"change": "Wijzigen",
|
||||
"check_now": "Nu controleren",
|
||||
"close": "Sluiten",
|
||||
"current_available": "Momenteel beschikbaar",
|
||||
"current_balance": "Actueel banksaldo",
|
||||
"date": "Datum",
|
||||
"description": "Beschrijving",
|
||||
@ -158,6 +161,8 @@
|
||||
"from": "Van",
|
||||
"generate_now": "Nu genereren",
|
||||
"lastname": "Achternaam",
|
||||
"link_amount": "Linkbedrag",
|
||||
"link_decay_description": "Het linkbedrag wordt geblokkeerd samen met de maximale vergankelijkheid. Nadat de link is ingewisseld, verlopen of verwijderd, wordt het resterende bedrag weer vrijgegeven.",
|
||||
"memo": "Memo",
|
||||
"message": "Bericht",
|
||||
"new_balance": "Nieuw banksaldo na bevestiging",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export const DECAY_START_TIME = new Date('2021-05-13T17:46:31Z')
|
||||
export const SECONDS_PER_YEAR_GREGORIAN_CALENDER = 31556952.0
|
||||
export const LOG4JS_BASE_CATEGORY_NAME = 'shared'
|
||||
export const REDEEM_JWT_TOKEN_EXPIRATION = '10m'
|
||||
1
shared/src/helper/index.ts
Normal file
1
shared/src/helper/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './updateField'
|
||||
68
shared/src/helper/updateField.test.ts
Normal file
68
shared/src/helper/updateField.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { updateAllDefinedAndChanged, updateIfDefinedAndChanged } from './updateField'
|
||||
|
||||
describe('updateIfDefinedAndChanged', () => {
|
||||
it('should update field if incoming is different from current', () => {
|
||||
const current = { field: 'current' }
|
||||
const incoming = 'incoming'
|
||||
const result = updateIfDefinedAndChanged(current, 'field', incoming)
|
||||
expect(result).toBe(true)
|
||||
expect(current.field).toBe('incoming')
|
||||
})
|
||||
it('should not update field if incoming is the same as current', () => {
|
||||
const current = { field: 'current' }
|
||||
const incoming = 'current'
|
||||
const result = updateIfDefinedAndChanged(current, 'field', incoming)
|
||||
expect(result).toBe(false)
|
||||
expect(current.field).toBe('current')
|
||||
})
|
||||
it('should not update field if incoming is undefined', () => {
|
||||
const current = { field: 'current' }
|
||||
const incoming = undefined
|
||||
const result = updateIfDefinedAndChanged(current, 'field', incoming)
|
||||
expect(result).toBe(false)
|
||||
expect(current.field).toBe('current')
|
||||
})
|
||||
it('should update field if incoming is null', () => {
|
||||
type TestEntity = { field: string | null }
|
||||
const current: TestEntity = { field: 'current' }
|
||||
const incoming = null
|
||||
const result = updateIfDefinedAndChanged(current, 'field', incoming)
|
||||
expect(result).toBe(true)
|
||||
expect(current.field).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateAllDefinedAndChanged', () => {
|
||||
it('should update all fields if incoming is different from current', () => {
|
||||
type TestEntity = { field1: string | null, field2: string | null, field3: string | null }
|
||||
const current: TestEntity = { field1: 'current', field2: 'current', field3: 'current' }
|
||||
const incoming = { field1: 'incoming', field2: 'incoming', otherField: 'incoming' }
|
||||
const result = updateAllDefinedAndChanged(current, incoming)
|
||||
expect(result).toBe(true)
|
||||
expect(current).toEqual({ field1: 'incoming', field2: 'incoming', field3: 'current' })
|
||||
})
|
||||
it('should not update any field if incoming is the same as current', () => {
|
||||
const current = { field1: 'current', field2: 'current' }
|
||||
const incoming = { field1: 'current', field2: 'current' }
|
||||
const result = updateAllDefinedAndChanged(current, incoming)
|
||||
expect(result).toBe(false)
|
||||
expect(current).toEqual({ field1: 'current', field2: 'current' })
|
||||
})
|
||||
it('should not update any field if incoming is undefined', () => {
|
||||
const current = { field1: 'current', field2: 'current' }
|
||||
const incoming = { field1: undefined, field2: undefined }
|
||||
const result = updateAllDefinedAndChanged(current, incoming)
|
||||
expect(result).toBe(false)
|
||||
expect(current).toEqual({ field1: 'current', field2: 'current' })
|
||||
})
|
||||
it('should update field if incoming is null', () => {
|
||||
type TestEntity = { field1: string | null, field2: string | null }
|
||||
type TestInput = { field1: string | null }
|
||||
const current: TestEntity = { field1: 'current', field2: 'current' }
|
||||
const incoming: TestInput = { field1: null }
|
||||
const result = updateAllDefinedAndChanged(current, incoming)
|
||||
expect(result).toBe(true)
|
||||
expect(current).toEqual({ field1: null, field2: 'current' })
|
||||
})
|
||||
})
|
||||
|
||||
39
shared/src/helper/updateField.ts
Normal file
39
shared/src/helper/updateField.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Updates a field if the incoming value is not undefined and not equal to the current value.
|
||||
* So basically undefined means don't touch value, null means set value to null.
|
||||
* @param current The current value of the field.
|
||||
* @param incoming The incoming value of the field.
|
||||
* @returns True if the field was updated, false otherwise.
|
||||
*/
|
||||
export function updateIfDefinedAndChanged<T, K extends keyof T>(
|
||||
entity: T,
|
||||
key: K,
|
||||
incoming: T[K] | undefined
|
||||
): boolean {
|
||||
if (typeof incoming === 'undefined') {
|
||||
return false
|
||||
}
|
||||
// Object.is compare actual values and return true if they are identical
|
||||
if (Object.is(entity[key], incoming)) {
|
||||
return false
|
||||
}
|
||||
entity[key] = incoming
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all keys of incoming and if exist on entity, call {@link updateIfDefinedAndChanged}
|
||||
* to update entity if value isn't undefined and not equal to current value.
|
||||
* @param entity The entity to update.
|
||||
* @param incoming The incoming values to update the entity with.
|
||||
* @returns True if at least one field was updated, false otherwise.
|
||||
*/
|
||||
export function updateAllDefinedAndChanged<T extends object>(entity: T, incoming: Partial<T>): boolean {
|
||||
let updated = false
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (key in entity && updateIfDefinedAndChanged(entity, key as keyof T, value as T[keyof T])) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
export * from './schema'
|
||||
export * from './enum'
|
||||
export * from './helper'
|
||||
export * from './logic/decay'
|
||||
export * from './jwt/JWT'
|
||||
export * from './jwt/payloadtypes/AuthenticationJwtPayloadType'
|
||||
@ -14,3 +15,4 @@ export * from './jwt/payloadtypes/SendCoinsJwtPayloadType'
|
||||
export * from './jwt/payloadtypes/SendCoinsResponseJwtPayloadType'
|
||||
export * from './jwt/payloadtypes/SignedTransferPayloadType'
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Decimal } from 'decimal.js-light'
|
||||
|
||||
import { calculateDecay, decayFormula } from './decay'
|
||||
import { calculateDecay, compoundInterest, decayFormula, decayFormulaFast } from './decay'
|
||||
|
||||
describe('utils/decay', () => {
|
||||
describe('decayFormula', () => {
|
||||
@ -10,6 +10,18 @@ describe('utils/decay', () => {
|
||||
// TODO: toString() was required, we could not compare two decimals
|
||||
expect(decayFormula(amount, seconds).toString()).toBe('0.999999978035040489732012')
|
||||
})
|
||||
|
||||
it('with large values', () => {
|
||||
const amount = new Decimal(100.0)
|
||||
const seconds = 1209600
|
||||
expect(decayFormula(amount, seconds).toString()).toBe('97.3781030481034505778419')
|
||||
})
|
||||
|
||||
it('with one year', () => {
|
||||
const amount = new Decimal(100.0)
|
||||
const seconds = 31556952
|
||||
expect(decayFormula(amount, seconds).toString()).toBe('49.99999999999999999999999')
|
||||
})
|
||||
|
||||
it('has correct backward calculation', () => {
|
||||
const amount = new Decimal(1.0)
|
||||
@ -26,6 +38,40 @@ describe('utils/decay', () => {
|
||||
expect(decayFormula(amount, seconds).toString()).toBe('1.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decayFormulaFast', () => {
|
||||
it('work like expected with small values', () => {
|
||||
const amount = new Decimal(1.0)
|
||||
const seconds = 1
|
||||
expect(decayFormulaFast(amount, seconds).toString()).toBe('0.999999978035040489732012')
|
||||
})
|
||||
it('work like expected with large values', () => {
|
||||
const amount = new Decimal(100.0)
|
||||
const seconds = 1209600
|
||||
expect(decayFormulaFast(amount, seconds).toString()).toBe('97.3781030481034505778419')
|
||||
})
|
||||
it('work like expected with one year', () => {
|
||||
const amount = new Decimal(100.0)
|
||||
const seconds = 31556952
|
||||
expect(decayFormulaFast(amount, seconds).toString()).toBe('50')
|
||||
})
|
||||
it('work correct when calculating future decay', () => {
|
||||
// for example on linked transaction
|
||||
// if I have amount = 100 GDD and want to know how much GDD I must lock to able to pay
|
||||
// decay in 14 days (1209600 seconds)
|
||||
const amount = new Decimal(100.0)
|
||||
const seconds = 1209600
|
||||
expect(compoundInterest(amount, seconds).toString()).toBe('102.6924912992003635568199')
|
||||
// if I lock 102.6924912992003635568199 GDD, I will have 99.99999999999999999999998 GDD after 14 days, not 100% but near enough
|
||||
expect(decayFormulaFast(compoundInterest(amount, seconds), seconds).toString()).toBe('99.99999999999999999999998')
|
||||
expect(compoundInterest(amount, seconds).toDecimalPlaces(4).toString()).toBe('102.6925')
|
||||
// rounded to 4 decimal places it is working like a charm
|
||||
expect(decayFormulaFast(new Decimal(
|
||||
compoundInterest(amount, seconds).toDecimalPlaces(4)),
|
||||
seconds
|
||||
).toDecimalPlaces(4).toString()).toBe('100')
|
||||
})
|
||||
})
|
||||
|
||||
it('has base 0.99999997802044727', () => {
|
||||
const now = new Date()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Decimal } from 'decimal.js-light'
|
||||
|
||||
import { DECAY_START_TIME } from '../const'
|
||||
import { DECAY_START_TIME, SECONDS_PER_YEAR_GREGORIAN_CALENDER } from '../const'
|
||||
|
||||
Decimal.set({
|
||||
precision: 25,
|
||||
@ -26,6 +26,13 @@ export function decayFormula(value: Decimal, seconds: number): Decimal {
|
||||
)
|
||||
}
|
||||
|
||||
export function decayFormulaFast(value: Decimal, seconds: number): Decimal {
|
||||
return value.mul(new Decimal(2).pow(new Decimal(-seconds).div(new Decimal(SECONDS_PER_YEAR_GREGORIAN_CALENDER))))
|
||||
}
|
||||
export function compoundInterest(value: Decimal, seconds: number): Decimal {
|
||||
return value.mul(new Decimal(2).pow(new Decimal(seconds).div(new Decimal(SECONDS_PER_YEAR_GREGORIAN_CALENDER))))
|
||||
}
|
||||
|
||||
export function calculateDecay(
|
||||
amount: Decimal,
|
||||
from: Date,
|
||||
|
||||
@ -22,6 +22,8 @@ const RESERVED_ALIAS = [
|
||||
'user',
|
||||
'usr',
|
||||
'var',
|
||||
'reserved',
|
||||
'undefined'
|
||||
]
|
||||
|
||||
export const aliasSchema = string()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user