feat: automatic merge two paid subs (email and tg)

This commit is contained in:
3252a8
2026-04-24 23:07:57 +03:00
parent 778615a97f
commit 7e26f9da9b
5 changed files with 539 additions and 36 deletions
+239 -12
View File
@@ -613,6 +613,11 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
final_user_id = user_id
final_email = email
final_panel_uuid: Optional[str] = None
async with async_session_factory() as session:
try:
@@ -643,15 +648,72 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
existing_email_user = await user_dal.get_user_by_email(session, email)
if existing_email_user and existing_email_user.user_id != current_user.user_id:
source_panel_uuid = existing_email_user.panel_user_uuid
current_user = await user_dal.merge_users(
session,
source_user_id=existing_email_user.user_id,
target_user_id=current_user.user_id,
)
merge_notice = await _build_account_merge_notice(
session,
merged_user=current_user,
source_user_id=existing_email_user.user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
await _sync_panel_identity_for_user(request, current_user)
await session.commit()
final_user_id = int(current_user.user_id)
final_panel_uuid = current_user.panel_user_uuid
if merge_notice:
merge_end_date_raw = merge_notice.get("final_end_date")
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw)
if merge_end_date_raw
else None
)
await _sync_panel_identity_for_user(
request,
current_user,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get("subscription_service")
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_payload = _build_account_merge_email(
merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
merge_notice,
)
try:
await email_service.send_custom_email(
email=final_email,
subject=email_payload["subject"],
body=email_payload["body"],
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
@@ -660,8 +722,12 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
logger.error("Email account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(current_user.user_id))
return web.json_response({"ok": True, "token": token})
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {"ok": True, "token": token}
if merge_notice:
response_payload["account_merge"] = merge_notice
response_payload["user_id"] = final_user_id
return web.json_response(response_payload)
async def account_telegram_link_route(request: web.Request) -> web.Response:
@@ -687,8 +753,20 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
merge_notice: Optional[Dict[str, Any]] = None
source_panel_uuid: Optional[str] = None
final_user_id = user_id
final_telegram_id: Optional[int] = None
final_email: Optional[str] = None
final_panel_uuid: Optional[str] = None
async with async_session_factory() as session:
try:
current_user_before_link = await user_dal.get_user_by_id(session, user_id)
if not current_user_before_link or current_user_before_link.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
source_panel_uuid = current_user_before_link.panel_user_uuid
db_user = await _link_telegram_to_user(
request,
session,
@@ -699,7 +777,67 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
final_user_id = int(db_user.user_id)
final_telegram_id = _telegram_id_for_user(db_user)
final_email = db_user.email
final_panel_uuid = db_user.panel_user_uuid
if final_user_id != user_id:
merge_notice = await _build_account_merge_notice(
session,
merged_user=db_user,
source_user_id=user_id,
source_panel_uuid=source_panel_uuid,
settings=settings,
)
await session.commit()
if merge_notice:
merge_end_date_raw = merge_notice.get("final_end_date")
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw)
if merge_end_date_raw
else None
)
await _sync_panel_identity_for_user(
request,
db_user,
expire_at=merge_end_date,
)
# Best-effort cleanup of the removed panel account after the DB merge.
if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
subscription_service: SubscriptionService = request.app.get("subscription_service")
if subscription_service and subscription_service.panel_service:
try:
await subscription_service.panel_service.delete_user_from_panel(
source_panel_uuid,
log_response=False,
)
except Exception as exc:
logger.warning(
"Failed to delete merged source panel user %s: %s",
source_panel_uuid,
exc,
)
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
email_payload = _build_account_merge_email(
merge_notice.get("language") or settings.DEFAULT_LANGUAGE,
merge_notice,
)
try:
await email_service.send_custom_email(
email=final_email,
subject=email_payload["subject"],
body=email_payload["body"],
)
except Exception as exc:
logger.warning(
"Failed to send account merge email to %s: %s",
final_email,
exc,
)
except UserMergeConflictError as exc:
await session.rollback()
return _json_error(409, "account_merge_conflict", str(exc))
@@ -708,14 +846,17 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
logger.error("Telegram account link failed: %s", exc, exc_info=True)
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {
"ok": True,
"token": token,
"user_id": int(final_user_id),
"telegram_id": final_telegram_id,
}
if merge_notice:
response_payload["account_merge"] = merge_notice
return web.json_response(
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
response_payload
)
@@ -913,12 +1054,17 @@ def _panel_description_for_user(user: User) -> str:
return "\n".join(line for line in lines if line).strip()
async def _sync_panel_identity_for_user(request: web.Request, user: User) -> None:
async def _sync_panel_identity_for_user(
request: web.Request,
user: User,
*,
expire_at: Optional[datetime] = None,
) -> bool:
if not user.panel_user_uuid:
return
return False
subscription_service: SubscriptionService = request.app.get("subscription_service")
if not subscription_service or not subscription_service.panel_service:
return
return False
payload: Dict[str, Any] = {
"description": _panel_description_for_user(user),
@@ -928,6 +1074,8 @@ async def _sync_panel_identity_for_user(request: web.Request, user: User) -> Non
payload["telegramId"] = telegram_id
if user.email:
payload["email"] = user.email
if expire_at is not None:
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
try:
await subscription_service.panel_service.update_user_details_on_panel(
@@ -935,12 +1083,91 @@ async def _sync_panel_identity_for_user(request: web.Request, user: User) -> Non
payload,
log_response=False,
)
return True
except Exception as exc:
logger.warning(
"Failed to sync linked identities to panel for user %s: %s",
user.user_id,
exc,
)
return False
def _format_webapp_datetime(value: Optional[datetime]) -> Optional[str]:
if not value:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
async def _build_account_merge_notice(
session: AsyncSession,
*,
merged_user: User,
source_user_id: int,
source_panel_uuid: Optional[str],
settings: Settings,
) -> Dict[str, Any]:
merged_subscription = None
if merged_user.panel_user_uuid:
merged_subscription = await subscription_dal.get_active_subscription_by_user_id(
session,
merged_user.user_id,
merged_user.panel_user_uuid,
)
if not merged_subscription:
merged_subscription = await subscription_dal.get_active_subscription_by_user_id(
session,
merged_user.user_id,
)
final_end_date = merged_subscription.end_date if merged_subscription else None
if final_end_date and final_end_date.tzinfo is None:
final_end_date = final_end_date.replace(tzinfo=timezone.utc)
return {
"merged": True,
"language": _normalize_language(merged_user.language_code or settings.DEFAULT_LANGUAGE),
"primary_user_id": int(merged_user.user_id),
"removed_user_id": int(source_user_id),
"primary_panel_user_uuid": merged_user.panel_user_uuid,
"removed_panel_user_uuid": source_panel_uuid,
"final_end_date": final_end_date.isoformat() if final_end_date else None,
"final_end_date_text": _format_webapp_datetime(final_end_date),
}
def _build_account_merge_email(language: str, merge_info: Dict[str, Any]) -> Dict[str, str]:
lang = _normalize_language(language)
primary_user_id = merge_info.get("primary_user_id")
removed_user_id = merge_info.get("removed_user_id")
final_end_date_text = (
merge_info.get("final_end_date_text")
or merge_info.get("final_end_date")
or "N/A"
)
if lang == "en":
return {
"subject": "Accounts merged",
"body": (
"We merged your accounts into one profile.\n\n"
f"Kept account: #{primary_user_id}\n"
f"Removed account: #{removed_user_id}\n"
f"Paid periods were combined. New subscription end date: {final_end_date_text}.\n"
"Your subscription link stayed the same, and the later account was removed from Remnawave automatically."
),
}
return {
"subject": "Аккаунты объединены",
"body": (
"Мы объединили ваши аккаунты в один профиль.\n\n"
f"Оставлен аккаунт: #{primary_user_id}\n"
f"Удалён аккаунт: #{removed_user_id}\n"
f"Оплаченные периоды сложились. Новая дата окончания подписки: {final_end_date_text}.\n"
"Ссылка на подписку осталась прежней, а более поздний аккаунт был удалён из Remnawave автоматически."
),
}
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
@@ -190,6 +190,11 @@
<section id="referral-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
</div>
<div id="account-merge-modal" class="modal modal--page hidden" role="dialog" aria-modal="true" aria-labelledby="account-merge-title" aria-describedby="account-merge-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeAccountMergeModal()"></button>
<section id="account-merge-panel" class="panel-modal modal-card grid gap-3.5 p-[17px]"></section>
</div>
<div id="email-code-modal" class="modal auth-code-modal hidden" role="dialog" aria-modal="true" aria-labelledby="email-code-title" aria-describedby="email-code-caption">
<button class="modal-backdrop" type="button" data-title-i18n="close" aria-label="Закрыть" onclick="closeEmailLoginCodeModal()"></button>
<div class="relative z-[1] grid w-[min(100%,420px)] justify-items-stretch gap-2.5">
+113 -3
View File
@@ -90,6 +90,8 @@ const MOCK = (() => {
paymentFlowOpen: false,
promoModalOpen: false,
referralModalOpen: false,
accountMergeModalOpen: false,
accountMergeNotice: null,
creatingPayment: false,
authInProgress: false,
authMode: (CFG.emailAuthEnabled === false ? 'telegram' : 'email'),
@@ -200,6 +202,13 @@ const MOCK = (() => {
email_rate_limited: 'Повторная отправка доступна через {seconds} сек.',
email_linked: 'Email привязан',
telegram_linked: 'Telegram привязан',
account_merge_toast: 'Аккаунты объединены',
account_merge_title: 'Аккаунты объединены',
account_merge_caption: 'Оплаченные периоды сложились, и теперь у вас один аккаунт.',
account_merge_body: 'Мы объединили ваш Telegram и email в один профиль. Оплаченные периоды сложились, ссылка на подписку осталась прежней, а более поздний аккаунт был удалён автоматически.',
account_merge_primary_account_label: 'Оставлен аккаунт',
account_merge_removed_account_label: 'Удалён аккаунт',
account_merge_end_date_label: 'Новая дата окончания',
account_merge_conflict: 'Этот аккаунт уже связан с другими данными.',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Проверяю вход...',
@@ -330,6 +339,13 @@ const MOCK = (() => {
email_rate_limited: 'Try again in {seconds} sec.',
email_linked: 'Email linked',
telegram_linked: 'Telegram linked',
account_merge_toast: 'Accounts merged',
account_merge_title: 'Accounts merged',
account_merge_caption: 'Your paid periods were combined and you now have one account.',
account_merge_body: 'We merged your Telegram and email accounts into one profile. Your paid periods were combined, your subscription link stayed the same, and the later account was removed automatically.',
account_merge_primary_account_label: 'Kept account',
account_merge_removed_account_label: 'Removed account',
account_merge_end_date_label: 'New end date',
account_merge_conflict: 'This account is already linked to different data.',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Verifying login...',
@@ -431,6 +447,8 @@ const MOCK = (() => {
if (event.key !== 'Escape') return;
if (state.emailLoginCodeModalOpen) {
closeEmailLoginCodeModal();
} else if (state.accountMergeModalOpen) {
closeAccountMergeModal();
} else if (state.promoModalOpen) {
closePromoModal();
} else if (state.referralModalOpen) {
@@ -904,6 +922,7 @@ const MOCK = (() => {
renderUserMenu(state.data.user || {});
renderAccount(state.data.user || {});
renderReferral(state.data.referral || {});
renderAccountMergeModal();
renderPaymentFlow();
}
@@ -1299,13 +1318,82 @@ const MOCK = (() => {
function closeToolModals() {
state.promoModalOpen = false;
state.referralModalOpen = false;
state.accountMergeModalOpen = false;
clearPromoStatus();
renderPromoModal();
renderReferral(state.data && state.data.referral || {});
renderAccountMergeModal();
}
function openAccountMergeModal(notice = null) {
if (notice) {
state.accountMergeNotice = notice;
}
state.accountMergeModalOpen = true;
renderAccountMergeModal();
}
function closeAccountMergeModal() {
state.accountMergeModalOpen = false;
renderAccountMergeModal();
}
function renderAccountMergeModal() {
const modal = document.getElementById('account-merge-modal');
const panel = document.getElementById('account-merge-panel');
if (!modal || !panel) return;
if (!state.accountMergeModalOpen) {
modal.classList.remove('show');
syncModalLock();
window.setTimeout(() => {
if (!state.accountMergeModalOpen) modal.classList.add('hidden');
}, 180);
return;
}
modal.classList.remove('hidden');
syncModalLock();
const notice = state.accountMergeNotice || {};
const primaryUserId = notice.primary_user_id != null ? String(notice.primary_user_id) : t('not_available');
const removedUserId = notice.removed_user_id != null ? String(notice.removed_user_id) : t('not_available');
const finalEndDateText = notice.final_end_date_text || t('not_available');
panel.innerHTML = `
<div class="${TW.panelHead}">
<div>
<div id="account-merge-title" class="${TW.sectionTitle} text-[var(--accent)]">${escapeHtml(t('account_merge_title'))}</div>
<div id="account-merge-caption" class="${TW.flowCaption}">${escapeHtml(t('account_merge_caption'))}</div>
</div>
<button class="${TW.iconBtn}" type="button" data-title-i18n="close" onclick="closeAccountMergeModal()">×</button>
</div>
<div class="notice grid gap-2">
<div class="text-[13px] leading-[1.5] text-[var(--text-secondary)]">${escapeHtml(t('account_merge_body'))}</div>
</div>
<div class="grid gap-2">
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div class="metric-label">${escapeHtml(t('account_merge_primary_account_label'))}</div>
<div class="metric-value">#${escapeHtml(primaryUserId)}</div>
</div>
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div class="metric-label">${escapeHtml(t('account_merge_removed_account_label'))}</div>
<div class="metric-value">#${escapeHtml(removedUserId)}</div>
</div>
<div class="metric rounded-[var(--radius-md)] grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div class="metric-label">${escapeHtml(t('account_merge_end_date_label'))}</div>
<div class="metric-value">${escapeHtml(finalEndDateText)}</div>
</div>
</div>
`;
applyI18n(modal);
window.requestAnimationFrame(() => modal.classList.add('show'));
}
function renderReferral(referral) {
const modal = document.getElementById('referral-modal');
const panel = document.getElementById('referral-panel');
@@ -1481,6 +1569,7 @@ const MOCK = (() => {
const email = state.emailLinkEmail || normalizeEmail(document.getElementById('email-link-input').value);
const code = document.getElementById('email-link-code-input').value;
setButtonBusy('email-link-verify-btn', true);
let mergeNotice = null;
try {
const data = await api('/account/email/verify', {
method: 'POST',
@@ -1488,9 +1577,21 @@ const MOCK = (() => {
});
if (!data.ok) throw data;
if (data.token) setToken(data.token);
showToast(t('email_linked'));
mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null;
state.accountMergeNotice = mergeNotice;
showToast(mergeNotice ? t('account_merge_toast') : t('email_linked'));
state.emailLinkEmail = '';
await loadData();
try {
await loadData();
if (mergeNotice) {
openAccountMergeModal(mergeNotice);
}
} catch (refreshError) {
console.warn('Email account linked, but data refresh failed', refreshError);
if (mergeNotice) {
openAccountMergeModal(mergeNotice);
}
}
} catch (e) {
showToast(emailErrorMessage(e, 'email_code_invalid'));
} finally {
@@ -1557,15 +1658,23 @@ const MOCK = (() => {
markTelegramLinked(data.telegram_id || fallbackTelegramId);
setTelegramLinkStatus('');
state.telegramLinkRendered = false;
const mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null;
state.accountMergeNotice = mergeNotice;
try {
showToast(t('telegram_linked'));
showToast(mergeNotice ? t('account_merge_toast') : t('telegram_linked'));
} catch (toastError) {
console.warn('Telegram link success toast failed', toastError);
}
try {
await loadData();
if (mergeNotice) {
openAccountMergeModal(mergeNotice);
}
} catch (refreshError) {
console.warn('Telegram account linked, but data refresh failed', refreshError);
if (mergeNotice) {
openAccountMergeModal(mergeNotice);
}
}
}
@@ -1636,6 +1745,7 @@ const MOCK = (() => {
|| state.emailLoginCodeModalOpen
|| state.promoModalOpen
|| state.referralModalOpen
|| state.accountMergeModalOpen
)
);
}