Skip to content
Open
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a5a0cbe
chore: create plan migration endpoint
dkrizan Jul 14, 2025
a090d1c
feat: cloud plans migration controller + UI
dkrizan Jul 17, 2025
00561df
feat: self-hosted migrations controller and UI
dkrizan Jul 17, 2025
0779d66
chore: test plan migration
dkrizan Jul 23, 2025
d2bef84
draft: migration history
dkrizan Jul 30, 2025
1962d00
chore: option to delete selfhosted plan migration, handle customer up…
dkrizan Jul 31, 2025
e902d3f
chore: real stripe test to schedule update and preview update
dkrizan Aug 1, 2025
fc52e06
feat: self-hosted plans - migrated subscriptions list
dkrizan Aug 4, 2025
4f7691f
feat: send plan change notice when migrated
dkrizan Aug 5, 2025
075ee69
chore: migration history refactored
dkrizan Aug 6, 2025
bd2655d
chore: migrator cache fix
dkrizan Aug 7, 2025
2769f77
chore: e2e tests - administration plan migrations
dkrizan Aug 7, 2025
83efb86
chore: test fix
dkrizan Aug 8, 2025
588c36d
chore: migration form refactored
dkrizan Aug 8, 2025
f6f901f
feat: allow upgrade free plans too
dkrizan Aug 11, 2025
44271f8
chore: release schedule on subscription update
dkrizan Aug 18, 2025
a4b61a9
chore: check-translations fix
dkrizan Aug 22, 2025
02cac56
feat: migration email content updated
dkrizan Oct 13, 2025
c044936
chore: CR fixes
dkrizan Oct 13, 2025
a02b0bf
chore: plan migration form refactored
dkrizan Oct 14, 2025
3b95305
chore: plan migration edit form refactored
dkrizan Oct 14, 2025
1cb0e3f
chore: CR refactors
dkrizan Oct 14, 2025
7145e10
chore: internal properties controller refactor
dkrizan Oct 14, 2025
4fb3159
chore: migration history refactored (renamed to migration record)
dkrizan Oct 14, 2025
69f6f64
chore: type
dkrizan Oct 15, 2025
6c48d28
fix: PlanMigration chip code improvements
dkrizan Oct 15, 2025
e8287d8
chore: coderabbit fixes
dkrizan Oct 15, 2025
673407f
chore: billingSchema updated
dkrizan Oct 16, 2025
f5cb4ad
chore: coderabbitai fixes
dkrizan Oct 16, 2025
6bb614c
chore: refactor plan migration create form
dkrizan Oct 17, 2025
c06de72
chore: coderabbit improvements
dkrizan Oct 17, 2025
c0eebc6
chore: coderabbit improvements 2
dkrizan Oct 17, 2025
e69fd5e
chore: coderabbit improvements 2
dkrizan Oct 17, 2025
1cb9b05
chore: error message for missing plan migration
dkrizan Oct 20, 2025
962e0d2
chore: schemas updated
dkrizan Oct 20, 2025
42559b8
chore: schemas updated
dkrizan Oct 20, 2025
9d162ab
chore: coderabbit
dkrizan Oct 20, 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 @@ -84,6 +86,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.*
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
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 @@ -311,6 +311,8 @@ enum class Message {
IMPERSONATION_OF_ADMIN_BY_SUPPORTER_NOT_ALLOWED,
ALREADY_IMPERSONATING_USER,
OPERATION_NOT_PERMITTED_IN_READ_ONLY_MODE,
PLAN_MIGRATION_NOT_FOUND,
PLAN_HAS_MIGRATIONS,
;

val code: String
Expand Down
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 at your next renewal, whether monthly or yearly, depending on your subscription. If you’d like to explore other subscriptions options, you can do so anytime in the <a href="{3}">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: [email protected]</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={4}">https://calendly.com/jancizmar/quick-chat-with-jan?month={4}</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 @@ -66,6 +66,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
7 changes: 7 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 @@ -263,6 +266,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 @@ -558,6 +562,7 @@ declare namespace DataCy {
"permissions-menu-save" |
"plan-limit-dialog-close" |
"plan-limit-exceeded-popover" |
"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 @@ -677,6 +682,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 Down Expand Up @@ -708,6 +714,7 @@ declare namespace DataCy {
"suggestions-list" |
"tag-autocomplete-input" |
"tag-autocomplete-option" |
"target-plan-selector" |
"task-date-picker" |
"task-detail" |
"task-detail-author" |
Expand Down
11 changes: 11 additions & 0 deletions webapp/src/component/common/FullWidthTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { styled, Tooltip, tooltipClasses, TooltipProps } from '@mui/material';

export const FullWidthTooltip = styled(
({ className, ...props }: TooltipProps) => (
<Tooltip {...props} classes={{ popper: className }} />
)
)({
[`& .${tooltipClasses.tooltip}`]: {
maxWidth: 'none',
},
});
28 changes: 22 additions & 6 deletions webapp/src/component/common/table/PaginatedHateoasTable.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { FC, JSXElementConstructor } from 'react';
import React, { FC, JSXElementConstructor, ReactNode } from 'react';
import {
HateoasListData,
HateoasPaginatedData,
Expand All @@ -8,7 +8,7 @@ import {
PaginatedHateoasList,
PaginatedHateoasListProps,
} from '../list/PaginatedHateoasList';
import { Table, TableBody } from '@mui/material';
import { Table, TableBody, TableHead } from '@mui/material';

export type PaginatedHateoasTableProps<
WrapperComponent extends
Expand All @@ -19,7 +19,9 @@ export type PaginatedHateoasTableProps<
> = Omit<
PaginatedHateoasListProps<WrapperComponent, typeof Table, TData, TItem>,
'listComponent'
>;
> & {
tableHead?: ReactNode;
};

export const PaginatedHateoasTable = <
WrapperComponent extends
Expand All @@ -30,17 +32,31 @@ export const PaginatedHateoasTable = <
>(
props: PaginatedHateoasTableProps<WrapperComponent, TData, TItem>
) => {
const { tableHead, ...rest } = props;
return (
<PaginatedHateoasList
listComponent={PaginatedHateoasTableListComponent}
{...props}
listComponent={(listProps) => (
<PaginatedHateoasTableListComponent
tableHead={tableHead}
{...listProps}
/>
)}
{...rest}
/>
);
};

const PaginatedHateoasTableListComponent: FC = ({ children }) => {
interface PaginatedHateoasTableListComponentProps {
children: ReactNode;
tableHead?: ReactNode;
}

const PaginatedHateoasTableListComponent: FC<
PaginatedHateoasTableListComponentProps
> = ({ children, tableHead }) => {
return (
<Table>
{tableHead && <TableHead>{tableHead}</TableHead>}
<TableBody>{children}</TableBody>
</Table>
);
Expand Down
7 changes: 7 additions & 0 deletions webapp/src/component/layout/HeaderBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type HeaderBarProps = {
switcher?: ReactNode;
maxWidth?: BaseViewWidth;
initialSearch?: string;
customButtons?: ReactNode[];
};

export const HeaderBar: React.VFC<HeaderBarProps> = (props) => {
Expand Down Expand Up @@ -81,6 +82,12 @@ export const HeaderBar: React.VFC<HeaderBarProps> = (props) => {
{props.switcher}
</Box>
)}
{props.customButtons &&
props.customButtons.map((button, index) => (
<Box key={index} display="flex" alignItems="center">
{button}
</Box>
))}
{props.addComponent
? props.addComponent
: (props.onAdd || props.addLinkTo) && (
Expand Down
14 changes: 14 additions & 0 deletions webapp/src/constants/GlobalValidationSchema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ Yup.setLocale({
/>
),
},
number: {
min: ({ min }) => (
<T
keyName="validation_schema_number_min_message"
params={{ min: min.toString() }}
/>
),
},
});

export class Validation {
Expand Down Expand Up @@ -521,6 +529,12 @@ export class Validation {
.required()
.matches(/^#[0-9A-F]{6}$/i, t('validation_invalid_hex_color')),
});

static readonly PLAN_MIGRATION_FORM = () =>
Yup.object().shape({
monthlyOffsetDays: Yup.number().required().min(0),
yearlyOffsetDays: Yup.number().required().min(0),
});
}

let GLOBAL_VALIDATION_DEBOUNCE_TIMER: any = undefined;
Expand Down
21 changes: 21 additions & 0 deletions webapp/src/constants/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export enum PARAMS {
TRANSLATION_ID = 'translationId',
PLAN_ID = 'planId',
TA_ID = 'taId',
PLAN_MIGRATION_ID = 'migrationId',
}

export class LINKS {
Expand Down Expand Up @@ -246,6 +247,26 @@ export class LINKS {
'create'
);

static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_CREATE = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS,
'create-migration'
);

static ADMINISTRATION_BILLING_CLOUD_PLAN_MIGRATION_EDIT = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_CLOUD_PLANS,
'migration/' + p(PARAMS.PLAN_MIGRATION_ID)
);

static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_CREATE = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_EE_PLANS,
'create-migration'
);

static ADMINISTRATION_BILLING_EE_PLAN_MIGRATION_EDIT = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_EE_PLANS,
'migration/' + p(PARAMS.PLAN_MIGRATION_ID)
);

/**
* Organizations
*/
Expand Down
Loading
Loading