Files
huly-platform/plugins/billing-resources/src/components/Subscriptions.svelte
T
Artyom SavchenkoandGitHub 93c2b4b694 Show payment error notifications (#10497)
* Show payment error notifications

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix year in copyright

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix duplicated ids

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
2026-02-12 11:11:28 +07:00

666 lines
21 KiB
Svelte

<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type SubscriptionData, SubscriptionType } from '@hcengineering/account-client'
import { type SubscribeRequest, type CheckoutStatus } from '@hcengineering/payment-client'
import { Tier } from '@hcengineering/billing'
import { getMetadata, translate } from '@hcengineering/platform'
import presentation, { getClient, MessageBox } from '@hcengineering/presentation'
import { type Ref, SortingOrder, UsageStatus } from '@hcengineering/core'
import {
IconCheckmark,
Label,
Loading,
Scroller,
Button,
getPlatformColorByName,
themeStore,
getLocation,
navigate,
showPopup,
addNotification,
NotificationSeverity
} from '@hcengineering/ui'
import { onMount, onDestroy } from 'svelte'
import plugin from '../plugin'
import { getAccountClient, getPaymentClient } from '../utils'
import UsageSection from './UsageSection.svelte'
import BillingErrorNotification from './BillingErrorNotification.svelte'
const client = getClient()
const paymentClient = getPaymentClient()
const tiers = client.getModel().findAllSync(plugin.class.Tier, {}, { sort: { index: SortingOrder.Ascending } })
const tierByPlan = tiers.reduce<Record<string, Tier>>((acc, tier) => {
const { plan } = getTypeAndPlan(tier._id)
acc[plan] = tier
return acc
}, {})
export let isReadOnly: boolean = false
let currentSubscription: SubscriptionData | undefined = undefined
$: currentTier = currentSubscription != null ? tierByPlan[currentSubscription.plan] : undefined
let loading = true
let pollingCheckoutId: string | null = null
let isPolling = false
let pollAttempts = 0
let pollTimer: number | undefined
let isUpdating = false
let isCanceling = false
let isUncanceling = false
const MAX_POLL_ATTEMPTS = 120
const POLL_INTERVAL = 2000
let usageInfo: UsageStatus | null = null
$: isCurrentCanceled = currentSubscription?.canceledAt !== undefined && currentSubscription.canceledAt > 0
async function subscribe (tierId: Ref<Tier>): Promise<void> {
if (paymentClient == null) {
return
}
const workspace = getMetadata(presentation.metadata.WorkspaceUuid)
if (workspace === undefined) {
console.warn('Workspace metadata not available')
return
}
try {
const request: SubscribeRequest = getTypeAndPlan(tierId)
const { checkoutUrl } = await paymentClient.createSubscription(workspace, request)
window.location.href = checkoutUrl
} catch (error) {
console.error('Error while upgrading plan:', error)
await showErrorNotification()
}
}
async function showErrorNotification (): Promise<void> {
addNotification(
await translate(plugin.string.SubscriptionOperationFailed, {}, $themeStore.language),
await translate(plugin.string.SubscriptionErrorMessage, {}, $themeStore.language),
BillingErrorNotification,
undefined,
NotificationSeverity.Error
)
}
async function showPlanChangeConfirmation (newPlan: string, newTier: Tier): Promise<void> {
if (currentTier === undefined) {
return
}
const isDowngrade = newTier.priceMonthly < currentTier.priceMonthly
const priceDifference = Math.abs(newTier.priceMonthly - currentTier.priceMonthly)
const title = isDowngrade ? plugin.string.ConfirmDowngrade : plugin.string.ConfirmUpgrade
const descriptionKey = isDowngrade ? plugin.string.DowngradeDescription : plugin.string.UpgradeDescription
showPopup(MessageBox, {
label: title,
message: descriptionKey,
params: { amount: priceDifference.toFixed(2) },
action: async () => {
await executeUpdate(newPlan)
}
})
}
async function handlePlanChange (newTierId: Ref<Tier>): Promise<void> {
const { plan: newPlan } = getTypeAndPlan(newTierId)
const newTier = tierByPlan[newPlan]
if (currentSubscription?.id === undefined) {
// No active subscription, create new one
await subscribe(newTierId)
return
}
if (currentTier === undefined) {
// No current tier selected, should not happen but guard against it
return
}
// If subscription is canceled, show uncancel confirmation first
if (isCurrentCanceled) {
showPopup(MessageBox, {
label: plugin.string.ConfirmUncancel,
message: plugin.string.UncancelDescription,
action: async () => {
// After uncanceling, show the plan change confirmation
await showPlanChangeConfirmation(newPlan, newTier)
}
})
} else {
await showPlanChangeConfirmation(newPlan, newTier)
}
}
async function executeUpdate (newPlan: string): Promise<void> {
if (paymentClient == null) {
return
}
if (currentSubscription?.id === undefined) {
return
}
try {
isUpdating = true
// If subscription is canceled, uncancel it first
if (isCurrentCanceled) {
currentSubscription = await paymentClient.uncancelSubscription(currentSubscription.id)
}
// Now update the plan
const updateResult = await paymentClient.updateSubscriptionPlan(currentSubscription.id, newPlan)
// Check if it's a CheckoutResponse (free-to-paid upgrade requires checkout)
if ('checkoutUrl' in updateResult) {
// Redirect to checkout URL for free-to-paid upgrade
window.location.href = (updateResult as any).checkoutUrl
return
}
// It's a SubscriptionData - direct update successful
currentSubscription = updateResult
} catch (error) {
console.error('Error updating subscription:', error)
await showErrorNotification()
} finally {
isUpdating = false
}
}
async function handleCancel (): Promise<void> {
if (currentSubscription?.id === undefined) {
return
}
if (isCurrentCanceled) {
return
}
showPopup(MessageBox, {
label: plugin.string.ConfirmCancel,
dangerous: true,
message: plugin.string.CancelDescription,
action: async () => {
await executeCancel()
}
})
}
async function executeCancel (): Promise<void> {
if (paymentClient == null) {
return
}
if (currentSubscription?.id === undefined) {
return
}
try {
isCanceling = true
currentSubscription = await paymentClient.cancelSubscription(currentSubscription.id)
} catch (error) {
console.error('Error canceling subscription:', error)
await showErrorNotification()
} finally {
isCanceling = false
}
}
async function handleUncancel (): Promise<void> {
if (currentSubscription?.id === undefined) {
return
}
if (!isCurrentCanceled) {
return
}
showPopup(MessageBox, {
label: plugin.string.ConfirmUncancel,
message: plugin.string.UncancelDescription,
action: async () => {
await executeUncancel()
}
})
}
async function executeUncancel (): Promise<void> {
if (paymentClient == null) {
return
}
if (currentSubscription?.id === undefined) {
return
}
if (!isCurrentCanceled) {
return
}
try {
isUncanceling = true
currentSubscription = await paymentClient.uncancelSubscription(currentSubscription.id)
} catch (error) {
console.error('Error uncanceling subscription:', error)
await showErrorNotification()
} finally {
isUncanceling = false
}
}
async function fetchSubscriptions (): Promise<void> {
loading = true
try {
const accountClient = getAccountClient()
if (accountClient == null) return
const subscriptions = await accountClient.getSubscriptions()
currentSubscription = subscriptions.find((p) => p.type === 'tier')
const plan = currentSubscription?.plan
currentTier = plan !== undefined ? tierByPlan[plan] : undefined
} catch (err) {
console.error('Error fetching current plan:', err)
await showErrorNotification()
} finally {
loading = false
}
}
async function fetchUsageStats (): Promise<void> {
try {
const accountClient = getAccountClient()
if (accountClient == null) return
const workspaceInfo = await accountClient.getWorkspaceInfo(false)
usageInfo = workspaceInfo.usageInfo ?? null
} catch (err) {
console.error('Error fetching usage stats:', err)
await showErrorNotification()
usageInfo = null
}
}
function formatSize (gb: number): { limit: number, unit: string } {
return gb < 1000 ? { limit: gb, unit: 'GB' } : { limit: Math.floor(gb / 1000), unit: 'TB' }
}
async function pollCheckoutStatus (checkoutId: string): Promise<void> {
if (paymentClient == null) {
return
}
if (isPolling || pollAttempts >= MAX_POLL_ATTEMPTS) {
return
}
isPolling = true
pollAttempts++
try {
const status: CheckoutStatus = await paymentClient.getCheckoutStatus(checkoutId)
if (status.status === 'completed') {
// Subscription is ready, refresh subscriptions and clean up URL
console.info('Checkout completed, subscription ready:', status.subscriptionId)
await fetchSubscriptions()
// Clean up the checkout_id from URL using navigate
const loc = getLocation()
const cleanedLoc = { ...loc, query: {} }
navigate(cleanedLoc)
pollingCheckoutId = null
pollAttempts = 0
} else {
// Still pending, poll again after delay
pollTimer = setTimeout(() => {
void pollCheckoutStatus(checkoutId)
}, POLL_INTERVAL)
}
} catch (err) {
console.error('Error polling checkout status:', err)
await showErrorNotification()
// Retry on error (up to max attempts)
if (pollAttempts < MAX_POLL_ATTEMPTS) {
pollTimer = setTimeout(() => {
void pollCheckoutStatus(checkoutId)
}, POLL_INTERVAL)
}
} finally {
isPolling = false
}
}
function checkForCheckoutParam (): void {
const loc = getLocation()
const checkoutId = loc.query?.checkout_id as string | undefined
const paymentStatus = loc.query?.payment as string | undefined
if (checkoutId !== undefined && paymentStatus === 'success') {
// Check if we already have a tier subscription that matches this checkout
const isMatchingSubscription = currentSubscription?.providerCheckoutId === checkoutId
if (!isMatchingSubscription) {
// No matching subscription found, start polling
pollingCheckoutId = checkoutId
pollAttempts = 0
void pollCheckoutStatus(checkoutId)
} else {
// Subscription already exists and matches this checkout, just clean up the URL
const cleanedLoc = { ...loc, query: {} }
navigate(cleanedLoc)
}
}
}
$: isCheckoutPolling = pollingCheckoutId !== null
function formatEndDate (endDate: number): string {
const date = new Date(endDate)
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
}
function getTypeAndPlan (tierId: Ref<Tier>): { type: SubscriptionType, plan: string } {
const parts = tierId.split(':')
if (parts.length !== 3) {
throw new Error(`Invalid tier id: ${tierId}`)
}
return {
type: parts[1] as SubscriptionType,
plan: parts[2].toLowerCase()
}
}
onMount(() => {
void (async () => {
// First, load current subscriptions
await fetchSubscriptions()
// Then fetch usage stats
await fetchUsageStats()
// Then check if we need to poll for a new subscription from checkout
checkForCheckoutParam()
})()
})
onDestroy(() => {
// Clean up any pending polling timer when component is destroyed
if (pollTimer !== undefined) {
clearTimeout(pollTimer)
}
})
</script>
{#if tiers.length > 0}
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content gapV-8">
<div class="flex-col flex-gap-4">
<div class="section-title">
<Label label={plugin.string.ActivePlan} />
</div>
<div class="current-tier-card w-full flex-gap-4">
{#if loading || isCheckoutPolling}
<Loading />
{#if isCheckoutPolling}
<div class="processing"><Label label={plugin.string.ProcessingPayment} /></div>
{/if}
{:else if currentTier === undefined}
<div class="no-plan-container flex-col flex-gap-4">
<div class="fs-title text-lg"><Label label={plugin.string.NoActivePlan} /></div>
<div class="text-md"><Label label={plugin.string.SelectPlanToBegin} /></div>
{#if usageInfo !== null}
<div class="usage-section">
<UsageSection usage={usageInfo} tier={currentTier} />
</div>
{/if}
</div>
{:else}
<div class="current-tier-card-title">
<div class="flex-row-center">
<div class="fs-title"><Label label={currentTier.label} /></div>
{#if currentSubscription?.status === 'active'}
<div class="status-badge ml-2 text-md"><Label label={plugin.string.Active} /></div>
{/if}
</div>
{#if currentSubscription?.amount}
<div class="flex-row-center items-end">
<span class="fs-title text-xl">
${currentSubscription?.amount / 100}
</span>
<span class="ml-1 lower">
<Label label={plugin.string.Monthly} />
</span>
</div>
{/if}
</div>
{#if usageInfo !== null}
<div class="usage-section">
<UsageSection usage={usageInfo} tier={currentTier} />
</div>
{/if}
<div class="curr-tier-footer">
{#if currentSubscription?.periodEnd}
{@const date = formatEndDate(currentSubscription.periodEnd)}
{#if isCurrentCanceled}
<div><Label label={plugin.string.SubscriptionValidUntil} params={{ date }} /></div>
{:else}
<div><Label label={plugin.string.SubscriptionRenews} params={{ date }} /></div>
{/if}
{/if}
{#if !isCurrentCanceled}
<Button
label={plugin.string.CancelSubscription}
kind="ghost"
disabled={loading || isCheckoutPolling || isCanceling}
on:click={() => {
void handleCancel()
}}
/>
{:else}
<Button
label={plugin.string.UncancelSubscription}
kind="primary"
disabled={loading || isCheckoutPolling || isUncanceling}
on:click={() => {
void handleUncancel()
}}
/>
{/if}
</div>
{/if}
</div>
</div>
<div class="flex-col flex-gap-4">
<div class="section-title">
<Label label={isReadOnly ? plugin.string.RestrictedPlans : plugin.string.AllPlans} />
</div>
<Scroller contentDirection="horizontal" buttons={false} showOverflowArrows shrink={false} noFade={false}>
<div class="flex-row-top flex-gap-4 flex-no-shrink mb-3">
{#each tiers as tier}
{@const color =
tier.color !== null && tier.color !== undefined && tier.color.length > 0
? getPlatformColorByName(tier.color, $themeStore.dark)
: null}
{@const bgAttr = $themeStore.dark ? 'background' : 'background-color'}
<div
class="tier-card"
style={color !== null && color !== undefined ? `${bgAttr}: ${color.background};` : ''}
>
<div class="tier-card-content">
<div class="fs-title text-lg">
<Label label={tier.label} />
</div>
<div class="flex-row-center items-end">
<span class="fs-title text-xl">
${tier.priceMonthly}
</span>
<span class="ml-1 lower">
<Label label={plugin.string.Monthly} />
</span>
</div>
<div class="mb-2 h-16">
<Label label={tier.description} />
</div>
<div class="tier-features">
<div class="feature-item">
<span class="feature-bullet"><IconCheckmark size="small" /></span>
<Label label={plugin.string.UnlimitedUsers} />
</div>
<div class="feature-item">
<span class="feature-bullet"><IconCheckmark size="small" /></span>
<Label label={plugin.string.UnlimitedObjects} />
</div>
<div class="feature-item">
<span class="feature-bullet"><IconCheckmark size="small" /></span>
<Label label={plugin.string.StorageLimit} params={{ ...formatSize(tier.storageLimitGB) }} />
</div>
<div class="feature-item">
<span class="feature-bullet"><IconCheckmark size="small" /></span>
<Label label={plugin.string.TrafficLimit} params={{ ...formatSize(tier.trafficLimitGB) }} />
</div>
</div>
</div>
<div class="tier-card-footer">
{#if !isReadOnly && (currentTier === undefined || currentTier._id !== tier._id)}
<Button
label={currentTier === undefined ? plugin.string.Subscribe : plugin.string.ChangePlan}
size={'large'}
kind={currentTier === undefined || tier.priceMonthly > currentTier.priceMonthly
? 'primary'
: 'regular'}
disabled={loading || isCheckoutPolling || isUpdating}
on:click={() => {
void handlePlanChange(tier._id)
}}
/>
{/if}
</div>
</div>
{/each}
</div>
</Scroller>
</div>
</div>
</Scroller>
{/if}
<style lang="scss">
.section-title {
font-weight: 500;
font-size: 1rem;
}
.current-tier-card {
display: flex;
flex-shrink: 0;
flex-direction: column;
width: 31rem;
border: 1px solid var(--theme-divider-color);
border-radius: var(--medium-BorderRadius);
padding: var(--spacing-2);
}
.current-tier-card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.status-badge {
color: var(--theme-state-positive-color);
background-color: var(--theme-state-positive-background-color);
border-radius: var(--small-BorderRadius);
padding: 0.125rem 0.5rem;
}
.tier-card {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-shrink: 0;
width: 15rem;
// max-height: 22rem;
border: 1px solid var(--theme-divider-color);
border-radius: var(--medium-BorderRadius);
padding: var(--spacing-2);
background-color: var(--theme-button-default);
}
.tier-card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-height: 0;
}
.tier-features {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.feature-item {
display: flex;
gap: var(--spacing-0_5);
font-size: 0.8125rem;
}
.feature-bullet {
color: var(--theme-state-positive-color);
font-weight: 600;
flex-shrink: 0;
}
.curr-tier-footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-2);
border-top: 1px solid var(--theme-divider-color);
}
.tier-card-footer {
display: flex;
flex-direction: row-reverse;
margin-top: var(--spacing-3);
height: 2.25rem;
}
.processing {
text-align: center;
}
.usage-section {
padding-top: var(--spacing-2);
border-top: 1px solid var(--theme-divider-color);
}
</style>