Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
65e2d7c
chore: create plan migration endpoint
dkrizan Jul 14, 2025
9896c97
feat: cloud plans migration controller + UI
dkrizan Jul 17, 2025
f284b1d
feat: self-hosted migrations controller and UI
dkrizan Jul 17, 2025
da30671
chore: test plan migration
dkrizan Jul 23, 2025
ee801e3
draft: migration history
dkrizan Jul 30, 2025
a9b8463
chore: option to delete selfhosted plan migration, handle customer up…
dkrizan Jul 31, 2025
9575259
chore: real stripe test to schedule update and preview update
dkrizan Aug 1, 2025
3c39fc9
feat: self-hosted plans - migrated subscriptions list
dkrizan Aug 4, 2025
b202ade
feat: send plan change notice when migrated
dkrizan Aug 5, 2025
e18d21b
chore: migration history refactored
dkrizan Aug 6, 2025
fe88513
chore: migrator cache fix
dkrizan Aug 7, 2025
1ae426e
chore: e2e tests - administration plan migrations
dkrizan Aug 7, 2025
c800dfc
chore: test fix
dkrizan Aug 8, 2025
493e964
chore: migration form refactored
dkrizan Aug 8, 2025
e2294cd
feat: allow upgrade free plans too
dkrizan Aug 11, 2025
f8cdafa
chore: release schedule on subscription update
dkrizan Aug 18, 2025
13c9bcf
chore: check-translations fix
dkrizan Aug 22, 2025
0924298
feat: migration email content updated
dkrizan Oct 13, 2025
e0bee28
chore: CR fixes
dkrizan Oct 13, 2025
977bfb6
chore: plan migration form refactored
dkrizan Oct 14, 2025
cdee642
chore: plan migration edit form refactored
dkrizan Oct 14, 2025
11c3f6b
chore: CR refactors
dkrizan Oct 14, 2025
3033146
chore: internal properties controller refactor
dkrizan Oct 14, 2025
5fc5548
chore: migration history refactored (renamed to migration record)
dkrizan Oct 14, 2025
31010ad
chore: type
dkrizan Oct 15, 2025
c444b7c
fix: PlanMigration chip code improvements
dkrizan Oct 15, 2025
dd0bf89
chore: coderabbit fixes
dkrizan Oct 15, 2025
482ecd8
chore: billingSchema updated
dkrizan Oct 16, 2025
1dbbff2
chore: coderabbitai fixes
dkrizan Oct 16, 2025
2d238c1
chore: refactor plan migration create form
dkrizan Oct 17, 2025
8b6994e
chore: coderabbit improvements
dkrizan Oct 17, 2025
bf4be43
chore: coderabbit improvements 2
dkrizan Oct 17, 2025
218216e
chore: coderabbit improvements 2
dkrizan Oct 17, 2025
80fe8c4
chore: error message for missing plan migration
dkrizan Oct 20, 2025
19321f5
chore: schemas updated
dkrizan Oct 20, 2025
4fc1b59
chore: schemas updated
dkrizan Oct 20, 2025
6129329
chore: coderabbit
dkrizan Oct 20, 2025
623e127
feat: plan migration email changed
dkrizan Nov 18, 2025
c7e53b2
feat: custom email in Plan migration form
dkrizan Nov 19, 2025
3ea67a4
feat: custom email in Plan migration form
dkrizan Nov 19, 2025
7407ad0
chore: ktlint
dkrizan Nov 19, 2025
b57796a
feat: alert of plan upgrade on Subscriptions page
dkrizan Nov 20, 2025
b7dd81f
feat: collapsible Email content editor in Plan migration detail.
dkrizan Nov 21, 2025
04a9ed5
feat: to be transferred subscription list
dkrizan Nov 24, 2025
0351e8d
feat: tooltips for cloud plan subscriptions on Cloud plans page
dkrizan Nov 26, 2025
d5388b1
chore: tsc & eslint fix
dkrizan Nov 26, 2025
4ad47cf
feat: tooltips for cloud plan subscriptions on Self hosted EE plans page
dkrizan Nov 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
import java.sql.Timestamp
import java.time.Duration
import java.time.LocalDate
import java.time.ZoneId.systemDefault
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor
Expand Down Expand Up @@ -86,6 +88,11 @@ class CurrentDateProvider(
return forcedDate ?: Date()
}

val localDate: LocalDate
get() {
return date.toInstant().atZone(systemDefault()).toLocalDate()
}

override fun getNow(): Optional<TemporalAccessor> {
return Optional.of(date.toInstant())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.tolgee.component
import io.tolgee.util.Logging
import io.tolgee.util.logger
import jakarta.annotation.PreDestroy
import org.springframework.scheduling.support.CronTrigger
import org.springframework.stereotype.Component
import java.time.Duration
import java.util.UUID
Expand Down Expand Up @@ -46,6 +47,19 @@ class SchedulingManager(
return id
}

fun scheduleWithCron(
runnable: Runnable,
cron: String,
): String {
val future = taskScheduler.schedule(runnable, CronTrigger(cron))
if (future == null) {
throw IllegalStateException("Future from scheduler was null")
}
val id = UUID.randomUUID().toString()
scheduledTasks[id] = future
return id
}

@PreDestroy
fun cancelAll() {
Companion.cancelAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.tolgee.component.email.customTemplate

import io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholdersExtractor
import org.springframework.stereotype.Component
import java.text.MessageFormat
import java.util.Locale
import kotlin.reflect.KClass

@Component
class EmailTemplateRenderer(
private val placeholderExtractor: EmailPlaceholdersExtractor,
) {
fun render(
template: String,
variables: EmailTemplateVariables,
): String {
@Suppress("UNCHECKED_CAST")
val entries =
placeholderExtractor.getEntries(
variables::class as KClass<EmailTemplateVariables>,
)

val parameters =
entries
.map { entry ->
entry.accessor(variables) ?: ""
}.toTypedArray()

return MessageFormat(template, Locale.ENGLISH).format(parameters)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.tolgee.component.email.customTemplate

/**
* Marker interface for classes that describe email template variables via [io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholder] annotations.
*/
interface EmailTemplateVariables
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.tolgee.component.email.customTemplate.placeholder

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class EmailPlaceholder(
val position: Int,
val placeholder: String,
val description: String,
val exampleValue: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.tolgee.component.email.customTemplate.placeholder

import io.tolgee.component.email.customTemplate.EmailTemplateVariables

data class EmailPlaceholderDefinition(
val position: Int,
val placeholder: String,
val description: String,
val exampleValue: String,
)

data class EmailPlaceholderEntry<T : EmailTemplateVariables>(
val definition: EmailPlaceholderDefinition,
val accessor: (T) -> String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.tolgee.component.email.customTemplate.placeholder

import io.tolgee.component.email.customTemplate.EmailTemplateVariables
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.set
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties

@Component
class EmailPlaceholdersExtractor {
private val cache =
ConcurrentHashMap<KClass<*>, List<EmailPlaceholderEntry<*>>>()

fun <T : EmailTemplateVariables> getEntries(kClass: KClass<T>): List<EmailPlaceholderEntry<T>> {
val existing = cache[kClass]
if (existing != null) {
@Suppress("UNCHECKED_CAST")
return existing as List<EmailPlaceholderEntry<T>>
}

val extracted = extract(kClass)
cache[kClass] = extracted
return extracted
}

fun <T : EmailTemplateVariables> getDefinitions(kClass: KClass<T>): List<EmailPlaceholderDefinition> {
return getEntries(kClass).map { it.definition }
}

private fun <T : EmailTemplateVariables> extract(kClass: KClass<T>): List<EmailPlaceholderEntry<T>> {
return kClass.memberProperties
.mapNotNull { property ->
val annotation = property.findAnnotation<EmailPlaceholder>() ?: return@mapNotNull null

EmailPlaceholderEntry(
definition =
EmailPlaceholderDefinition(
position = annotation.position,
placeholder = annotation.placeholder,
description = annotation.description,
exampleValue = annotation.exampleValue,
),
accessor = { instance: T ->
property.get(instance)?.toString()
},
)
}.sortedBy { it.definition.position }
}
}
2 changes: 2 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ enum class Message {
ALREADY_IMPERSONATING_USER,
OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE,
FILE_PROCESSING_FAILED,
PLAN_MIGRATION_NOT_FOUND,
PLAN_HAS_MIGRATIONS,
;

val code: String
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.tolgee.dtos.misc

data class EmailPlaceholderModel(
val placeholder: String,
val description: String,
val exampleValue: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.tolgee.dtos.misc

data class EmailTemplateModel(
val body: String,
val placeholders: List<EmailPlaceholderModel>,
)
21 changes: 21 additions & 0 deletions backend/data/src/main/resources/I18n_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,24 @@ notifications.email.security-settings-link=Check your security settings <a href=
notifications.email.mfa.MFA_ENABLED=Multi-factor authentication has been enabled for your account.
notifications.email.mfa.MFA_DISABLED=Multi-factor authentication has been disabled for your account.
notifications.email.password-changed=Password has been changed for your account.

notifications.email.plan-migration-subject=Upcoming update to your Tolgee subscription plan
notifications.email.plan-migration-body=Dear {0},<br/>\
<br/>\
Earlier this year, we introduced a new pricing structure for new Tolgee customers while keeping existing users on their current plans. We’re now moving all subscriptions to the new plans to make things simpler and fair for everyone.<br/>\
<br/>\
In the new structure, translation strings have been replaced with keys and seats to better reflect how Tolgee is used. You can view the updated pricing here <a href="https://tolgee.io/pricing">https://tolgee.io/pricing</a>.<br/>\
<br/>\
Your current {1} plan will automatically switch to the new {2} plan on {3}. If you’d like to explore other subscriptions options, you can do so anytime in the <a href="{4}">Subscriptions</a> section within the Tolgee platform.<br/>\
<br/>\
Thank you for your continued trust and support!<br/>\
<br/>\
P.S. I understand these changes may cause discomfort and not accommodate everyone’s circumstances. If this materially impacts your business, I warmly invite you to contact me directly for a personal dialogue regarding your concerns. You can reach me at:<br/>\
<ul>\
<li>Email: jan@tolgee.io</li>\
<li><a href="https://tolg.ee/slack">Tolgee Slack</a></li>\
<li>Schedule a meeting via Calendly <a href="https://calendly.com/jancizmar/quick-chat-with-jan?month={5}">https://calendly.com/jancizmar/quick-chat-with-jan?month={5}</a></li>\
</ul>\
Warm regards,<br/>\
Jan<br/>\
CEO, Tolgee
Original file line number Diff line number Diff line change
@@ -1,47 +1,24 @@
package io.tolgee.controllers.internal

import io.tolgee.configuration.tolgee.E2eRuntimeMutable
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.dtos.request.SetPropertyDto
import io.tolgee.exceptions.BadRequestException
import io.tolgee.exceptions.NotFoundException
import io.tolgee.facade.InternalPropertiesSetterFacade
import jakarta.validation.Valid
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.hasAnnotation

@InternalController(["internal/properties"])
class PropertiesController(
val tolgeeProperties: TolgeeProperties,
private val tolgeeProperties: TolgeeProperties,
private val internalPropertiesSetterFacade: InternalPropertiesSetterFacade,
) {
@PutMapping(value = ["/set"])
@Transactional
fun setProperty(
@RequestBody @Valid
setPropertyDto: SetPropertyDto,
) {
val name = setPropertyDto.name
var instance: Any = tolgeeProperties
name.split(".").let { namePath ->
namePath.forEachIndexed { idx, property ->
val isLast = idx == namePath.size - 1
val props = instance::class.declaredMemberProperties
val prop = props.find { it.name == property } ?: throw NotFoundException()
if (isLast) {
(prop as? KMutableProperty1<Any, Any?>)?.let {
if (!it.hasAnnotation<E2eRuntimeMutable>()) {
io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE
}
it.set(instance, setPropertyDto.value)
return
} ?: throw BadRequestException(io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE)
}
instance = (prop as KProperty1<Any, Any?>).get(instance)!!
}
}
internalPropertiesSetterFacade.setProperty(tolgeeProperties, setPropertyDto)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.tolgee.facade

import io.tolgee.configuration.tolgee.E2eRuntimeMutable
import io.tolgee.dtos.request.SetPropertyDto
import io.tolgee.exceptions.BadRequestException
import io.tolgee.exceptions.NotFoundException
import org.springframework.stereotype.Component
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.hasAnnotation

@Component
class InternalPropertiesSetterFacade {
fun setProperty(
root: Any,
setPropertyDto: SetPropertyDto,
onSet: (() -> Unit)? = null,
) {
val name = setPropertyDto.name
var instance: Any = root
name.split(".").let { namePath ->
namePath.forEachIndexed { idx, property ->
val isLast = idx == namePath.size - 1
val props = instance::class.declaredMemberProperties
val prop = props.find { it.name == property } ?: throw NotFoundException()
if (isLast) {
(prop as? KMutableProperty1<Any, Any?>)?.let {
if (!it.hasAnnotation<E2eRuntimeMutable>()) {
io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE
}
it.set(instance, setPropertyDto.value)
onSet?.invoke()
return
} ?: throw BadRequestException(io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE)
}
instance = (prop as KProperty1<Any, Any?>).get(instance)
?: throw NotFoundException()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.mockito.Mockito
import org.mockito.kotlin.KArgumentCaptor
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
Expand Down Expand Up @@ -72,6 +73,10 @@ class EmailTestUtil {
verify(javaMailSender).send(any<MimeMessage>())
}

fun verifyTimesEmailSent(num: Int) {
verify(javaMailSender, times(num)).send(any<MimeMessage>())
}

val assertEmailTo: AbstractStringAssert<*>
get() {
@Suppress("CAST_NEVER_SUCCEEDS")
Expand Down
13 changes: 13 additions & 0 deletions e2e/cypress/support/dataCyType.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ declare namespace DataCy {
"administration-plan-field-stripe-product" |
"administration-plan-field-stripe-product-name" |
"administration-plan-selector" |
"administration-plans-create-migration" |
"administration-plans-edit-migration" |
"administration-plans-item-is-migrating-badge" |
"administration-subscriptions-active-self-hosted-ee-plan" |
"administration-subscriptions-assign-plan-save-button" |
"administration-subscriptions-cloud-plan-name" |
Expand Down Expand Up @@ -197,6 +200,7 @@ declare namespace DataCy {
"billing-plan-included-keys" |
"billing-plan-included-seats" |
"billing-plan-included-strings" |
"billing-plan-migration-alert" |
"billing-plan-monthly-price" |
"billing-plan-price-extra-seat" |
"billing-plan-price-extra-thousand-keys" |
Expand Down Expand Up @@ -263,6 +267,7 @@ declare namespace DataCy {
"create-task-submit" |
"dashboard-projects-list-item" |
"default-namespace-select" |
"delete-plan-migration-button" |
"delete-user-button" |
"developer-menu-content-delivery" |
"developer-menu-storage" |
Expand Down Expand Up @@ -568,6 +573,9 @@ declare namespace DataCy {
"permissions-menu-save" |
"plan-limit-dialog-close" |
"plan-limit-exceeded-popover" |
"plan-migration-tab-migrated" |
"plan-migration-tab-upcoming" |
"plan-migration-tooltip-detail" |
"plan_seat_limit_exceeded_while_accepting_invitation_message" |
"project-ai-prompt-dialog-description-input" |
"project-ai-prompt-dialog-save" |
Expand Down Expand Up @@ -679,6 +687,7 @@ declare namespace DataCy {
"seat_spending_limit_exceeded_while_accepting_invitation_message" |
"self-hosted-ee-active-plan" |
"self-hosted-ee-subscription-metrics" |
"send-preview-email" |
"sensitive-dialog-otp-input" |
"sensitive-dialog-password-input" |
"sensitive-protection-dialog" |
Expand All @@ -687,6 +696,7 @@ declare namespace DataCy {
"signup-error-free-seat-limit" |
"signup-error-plan-seat-limit" |
"signup-error-seats-spending-limit" |
"source-plan-selector" |
"spending-limit-dialog-close" |
"spending-limit-exceeded-popover" |
"sso-migration-info-text" |
Expand All @@ -711,13 +721,16 @@ declare namespace DataCy {
"storage-subtitle" |
"submenu-item" |
"subscribe-cancels-trial-plan-tooltip" |
"subscription-period-" |
"subscription-status-" |
"subscriptions-cloud-popover-active-plan-name" |
"subscriptions-trial-alert" |
"subscriptions-trial-alert-reaching-the-limit" |
"suggestion-action" |
"suggestions-list" |
"tag-autocomplete-input" |
"tag-autocomplete-option" |
"target-plan-selector" |
"task-date-picker" |
"task-detail" |
"task-detail-author" |
Expand Down
Loading
Loading