Subscriptions (#10114)

This commit is contained in:
Alexey Zinoviev
2025-10-30 10:45:20 +07:00
committed by GitHub
parent f7df43a130
commit df50eab941
129 changed files with 5489 additions and 637 deletions
+21
View File
@@ -740,6 +740,27 @@
"sourceMaps": true,
"cwd": "${workspaceRoot}/services/sign/pod-sign"
},
{
"name": "Debug payment",
"type": "node",
"request": "launch",
"args": ["src/index.ts"],
"env": {
"PORT": "3040",
"SECRET": "secret",
"SERVICE_ID": "payment-service",
"ACCOUNTS_URL": "http://huly.local:3000",
"FRONT_URL": "http://huly.local:8087",
"USE_SANDBOX": "true",
// "POLAR_ACCESS_TOKEN": "polar_xxx",
// "POLAR_WEBHOOK_SECRET": "whsec_xxx",
// "POLAR_SUBSCRIPTION_PLANS": "common@tier:prod_1a,prod_1b;rare@tier:prod_2;epic@tier:prod_3;legendary@tier:prod_4"
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeVersion": "20",
"sourceMaps": true,
"cwd": "${workspaceRoot}/services/payment/pod-payment"
},
{
"name": "Debug rekoni",
"type": "node",
+602 -424
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -25,5 +25,6 @@ rush docker:build -p 20 \
--to @hcengineering/pod-backup \
--to @hcengineering/backup-api-pod \
--to @hcengineering/pod-billing \
--to @hcengineering/pod-process \
--to @hcengineering/pod-rating
--to @hcengineering/pod-process \
--to @hcengineering/pod-rating \
--to @hcengineering/pod-payment
+1
View File
@@ -358,6 +358,7 @@ export async function configurePlatform (onWorkbenchConnect?: () => Promise<void
setMetadata(exportPlugin.metadata.ExportUrl, config.EXPORT_URL ?? '')
setMetadata(billingPlugin.metadata.BillingURL, config.BILLING_URL ?? '')
setMetadata(presentation.metadata.PaymentUrl, config.PAYMENT_URL ?? '')
const languages = myBranding.languages !== undefined && myBranding.languages !== '' ? myBranding.languages.split(',').map((l) => l.trim()) : ['en', 'ru', 'es', 'pt', 'zh', 'fr', 'cs', 'it', 'de', 'ja', 'tr']
+1
View File
@@ -64,6 +64,7 @@ export interface Config {
MAIL_URL?: string
COMMUNICATION_API_ENABLED?: string
BILLING_URL?: string
PAYMENT_URL?: string
PULSE_URL?: string
PASSWORD_STRICTNESS?: 'very_strict' | 'strict' | 'normal' | 'none'
EXCLUDED_APPLICATIONS_FOR_ANONYMOUS?: string
+17
View File
@@ -123,6 +123,22 @@ services:
- PORT=4900
- SERVER_SECRET=secret
restart: unless-stopped
payment:
image: hardcoreeng/payment
extra_hosts:
- 'huly.local:host-gateway'
ports:
- 3040:3040
environment:
- SECRET=secret
- PORT=3040
- ACCOUNTS_URL=http://huly.local:3000
- FRONT_URL=http://huly.local:8087
- USE_SANDBOX=true
- POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN}
- POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET}
- POLAR_SUBSCRIPTION_PLANS=${POLAR_SUBSCRIPTION_PLANS}
restart: unless-stopped
workspace_cockroach:
image: hardcoreeng/workspace
extra_hosts:
@@ -203,6 +219,7 @@ services:
- DESKTOP_UPDATES_CHANNELS=dev;tracex:dev-tracex
- BRANDING_URL=http://huly.local:8087/branding.json
- STREAM_URL=http://huly.local:1080/recording
- PAYMENT_URL=http://huly.local:3040
# - DISABLE_SIGNUP=true
restart: unless-stopped
transactor_cockroach:
+18
View File
@@ -199,6 +199,23 @@ services:
- SERVER_SECRET=secret
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
restart: unless-stopped
payment:
image: hardcoreeng/payment
extra_hosts:
- 'huly.local:host-gateway'
ports:
- 3040:3040
environment:
- SECRET=secret
- PORT=3040
- ACCOUNTS_URL=http://huly.local:3000
- FRONT_URL=http://huly.local:8087
- USE_SANDBOX=true
- POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN}
- POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET}
- POLAR_SUBSCRIPTION_PLANS=${POLAR_SUBSCRIPTION_PLANS}
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
restart: unless-stopped
workspace_cockroach:
image: hardcoreeng/workspace
extra_hosts:
@@ -279,6 +296,7 @@ services:
- DESKTOP_UPDATES_CHANNELS=dev;tracex:dev-tracex
- BRANDING_URL=http://huly.local:8087/branding.json
- STREAM_URL=http://huly.local:1080/recording
- PAYMENT_URL=http://huly.local:3040
- COMMUNICATION_API_ENABLED=true
- BACKUP_URL=http://huly.local:4039/api/backup,
- EXCLUDED_APPLICATIONS_FOR_ANONYMOUS=["chunter", "notification"]
+1
View File
@@ -22,6 +22,7 @@
"STATS_URL": "http://huly.local:4900",
"PASSWORD_STRICTNESS": "none",
"STREAM_URL": "http://huly.local:1080/recording",
"PAYMENT_URL": "http://huly.local:3040",
"PUBLIC_SCHEDULE_URL": "http://huly.local:8060",
"CALDAV_SERVER_URL": "http://huly.local:9070",
"EXPORT_URL": "http://huly.local:4009",
+2
View File
@@ -201,6 +201,7 @@ export interface Config {
MAIL_URL?: string
COMMUNICATION_API_ENABLED?: string
BILLING_URL?: string
PAYMENT_URL?: string
EXCLUDED_APPLICATIONS_FOR_ANONYMOUS?: string
PULSE_URL?: string
HULYLAKE_URL?: string
@@ -514,6 +515,7 @@ export async function configurePlatform () {
setMetadata(exportPlugin.metadata.ExportUrl, config.EXPORT_URL ?? '')
setMetadata(billingPlugin.metadata.BillingURL, config.BILLING_URL ?? '')
setMetadata(presentation.metadata.PaymentUrl, config.PAYMENT_URL ?? '')
setMetadata(presentation.metadata.PulseUrl, config.PULSE_URL)
setMetadata(presentation.metadata.HulylakeUrl, config.HULYLAKE_URL ?? '')
+1 -1
View File
@@ -11,7 +11,7 @@
"test": "echo \"No test specified\""
},
"devDependencies": {
"@hcengineering/platform": "^0.7.5",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/theme": "^0.7.0",
"@hcengineering/ui": "^0.7.0",
"@storybook/addon-essentials": "^7.0.6",
+1 -1
View File
@@ -95,7 +95,7 @@
"@hcengineering/model-activity": "^0.7.0",
"@hcengineering/model-lead": "^0.7.0",
"@hcengineering/postgres": "^0.7.15",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/mongo": "^0.7.15",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/recruit": "^0.7.0",
+2 -1
View File
@@ -38,6 +38,7 @@
"@hcengineering/model": "^0.7.17",
"@hcengineering/model-core": "^0.7.0",
"@hcengineering/setting": "^0.7.0",
"@hcengineering/billing": "^0.7.0"
"@hcengineering/billing": "^0.7.0",
"@hcengineering/platform": "^0.7.17"
}
}
+83 -8
View File
@@ -13,23 +13,98 @@
// limitations under the License.
//
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import { Model, UX, type Builder } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import { type IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import billingPlugin from '@hcengineering/billing'
import { AccountRole } from '@hcengineering/core'
import billing, { type Tier } from '@hcengineering/billing'
import { AccountRole, DOMAIN_MODEL } from '@hcengineering/core'
export { billingId } from '@hcengineering/billing'
export { billingPlugin as default }
export { billing as default }
@Model(billing.class.Tier, core.class.Doc, DOMAIN_MODEL)
@UX(billing.string.Tier)
export class TTier extends TDoc implements Tier {
label!: IntlString
description!: IntlString
storageLimitGB!: number
trafficLimitGB!: number
priceMonthly!: number
index!: number
color?: string
}
export function createModel (builder: Builder): void {
builder.createModel(TTier)
builder.createDoc(setting.class.WorkspaceSettingCategory, core.space.Model, {
name: 'billing',
label: billingPlugin.string.Billing,
icon: billingPlugin.icon.Billing,
component: billingPlugin.component.Settings,
label: billing.string.Billing,
icon: billing.icon.Billing,
component: billing.component.Settings,
group: 'settings-editor',
role: AccountRole.Owner,
order: 920
})
builder.createDoc(
billing.class.Tier,
core.space.Model,
{
label: billing.string.Common,
description: billing.string.CommonDescription,
storageLimitGB: 10,
trafficLimitGB: 10,
priceMonthly: 0,
index: 0
},
billing.tier.Common
)
builder.createDoc(
billing.class.Tier,
core.space.Model,
{
label: billing.string.Rare,
description: billing.string.RareDescription,
storageLimitGB: 100,
trafficLimitGB: 100,
priceMonthly: 19.99,
index: 1,
color: 'Sky'
},
billing.tier.Rare
)
builder.createDoc(
billing.class.Tier,
core.space.Model,
{
label: billing.string.Epic,
description: billing.string.EpicDescription,
storageLimitGB: 1000,
trafficLimitGB: 500,
priceMonthly: 99.99,
index: 2,
color: 'Orchid'
},
billing.tier.Epic
)
builder.createDoc(
billing.class.Tier,
core.space.Model,
{
label: billing.string.Legendary,
description: billing.string.LegendaryDescription,
storageLimitGB: 10000,
trafficLimitGB: 2000,
priceMonthly: 399.99,
index: 3,
color: 'Orange'
},
billing.tier.Legendary
)
}
+1 -1
View File
@@ -48,7 +48,7 @@
"@hcengineering/model-view": "^0.7.0",
"@hcengineering/model-setting": "^0.7.0",
"@hcengineering/model-workbench": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/activity": "^0.7.0",
"@hcengineering/workbench": "^0.7.0",
"@hcengineering/model-preference": "^0.7.0",
+1 -1
View File
@@ -42,7 +42,7 @@
"@types/uuid": "^8.3.1"
},
"dependencies": {
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/core": "^0.7.18",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/kvs-client": "^0.7.0",
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+4
View File
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@hcengineering/payment-client",
"version": "0.7.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"files": [
"lib/**/*",
"types/**/*",
"tsconfig.json"
],
"author": "Hardcore Engineering Inc.",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"format": "format src",
"test": "jest --passWithNoTests --silent",
"_phase:build": "compile transpile src",
"_phase:test": "jest --passWithNoTests --silent",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"cross-env": "~7.0.3",
"@hcengineering/platform-rig": "^0.7.19",
"@types/node": "^22.15.29",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-n": "^15.4.0",
"eslint": "^8.54.0",
"esbuild": "^0.25.9",
"@typescript-eslint/parser": "^6.21.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/core": "^0.7.18",
"@hcengineering/platform": "^0.7.17"
},
"repository": "https://github.com/hcengineering/platform",
"publishConfig": {
"access": "public"
},
"exports": {
".": {
"types": "./types/index.d.ts",
"require": "./lib/index.js",
"import": "./lib/index.js"
}
}
}
+175
View File
@@ -0,0 +1,175 @@
//
// 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.
//
import { concatLink, type WorkspaceUuid } from '@hcengineering/core'
import { CheckoutResponse, SubscribeRequest, CheckoutStatus, SubscriptionData } from './types'
import { PaymentError, NetworkError } from './error'
/**
* Create a payment client instance
* @param paymentUrl - URL of the payment service
* @param token - Authentication token
* @returns PaymentClient instance
*/
export function getClient (paymentUrl?: string, token?: string): PaymentClient {
if (paymentUrl === undefined || paymentUrl == null || paymentUrl === '') {
throw new Error('Payment service URL not specified')
}
if (token === undefined || token == null || token === '') {
throw new Error('Authentication token not specified')
}
return new PaymentClient(paymentUrl, token)
}
/**
* Payment service client
* Handles all subscription and payment operations
*/
export class PaymentClient {
private readonly headers: Record<string, string>
constructor (
private readonly endpoint: string,
private readonly token: string
) {
this.headers = {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
}
}
/**
* Create a subscription for a workspace
* @param workspace - Workspace UUID
* @param request - Subscription request details
* @returns Checkout details with URL for payment
*/
async createSubscription (workspace: WorkspaceUuid, request: SubscribeRequest): Promise<CheckoutResponse> {
const path = `/api/v1/subscriptions/${workspace}/subscribe`
const url = new URL(concatLink(this.endpoint, path))
const body = JSON.stringify(request)
const response = await fetchSafe(url, {
method: 'POST',
headers: { ...this.headers },
body
})
return (await response.json()) as CheckoutResponse
}
/**
* Get subscription details
* @param subscriptionId - Subscription ID
* @returns Subscription details from payment provider
*/
async getSubscription (subscriptionId: string): Promise<any> {
const path = `/api/v1/subscriptions/${subscriptionId}`
const url = new URL(concatLink(this.endpoint, path))
const response = await fetchSafe(url, { headers: { ...this.headers } })
return await response.json()
}
/**
* Cancel a subscription
* @param subscriptionId - Subscription ID to cancel
* @returns Cancellation confirmation
*/
async cancelSubscription (subscriptionId: string): Promise<SubscriptionData> {
const path = `/api/v1/subscriptions/${subscriptionId}/cancel`
const url = new URL(concatLink(this.endpoint, path))
const response = await fetchSafe(url, {
method: 'POST',
headers: { ...this.headers }
})
return (await response.json()) as SubscriptionData
}
/**
* Uncancel a subscription (reactivate a previously canceled subscription)
* @param subscriptionId - Subscription ID to uncancel
* @returns Reactivation confirmation
*/
async uncancelSubscription (subscriptionId: string): Promise<SubscriptionData> {
const path = `/api/v1/subscriptions/${subscriptionId}/uncancel`
const url = new URL(concatLink(this.endpoint, path))
const response = await fetchSafe(url, {
method: 'POST',
headers: { ...this.headers }
})
return (await response.json()) as SubscriptionData
}
/**
* Update a subscription to a different plan
* For free-to-paid upgrades, returns CheckoutResponse (requires checkout)
* For paid-to-paid updates, returns SubscriptionData (direct update)
* @param subscriptionId - Subscription ID to update
* @param plan - New plan name
* @returns CheckoutResponse for free-to-paid upgrades or updated SubscriptionData for direct updates
*/
async updateSubscriptionPlan (subscriptionId: string, plan: string): Promise<SubscriptionData | CheckoutResponse> {
const path = `/api/v1/subscriptions/${subscriptionId}/updatePlan`
const url = new URL(concatLink(this.endpoint, path))
const body = JSON.stringify({ plan })
const response = await fetchSafe(url, {
method: 'POST',
headers: { ...this.headers },
body
})
return (await response.json()) as SubscriptionData | CheckoutResponse
}
/**
* Get checkout status
* Poll this endpoint after user returns from payment provider to check if subscription is ready
* @param checkoutId - Checkout ID returned from createSubscription
* @returns Checkout status with subscription details if completed
*/
async getCheckoutStatus (checkoutId: string): Promise<CheckoutStatus> {
const path = `/api/v1/checkouts/${checkoutId}/status`
const url = new URL(concatLink(this.endpoint, path))
const response = await fetchSafe(url, { headers: { ...this.headers } })
return (await response.json()) as CheckoutStatus
}
}
/**
* Safe fetch wrapper that handles errors consistently
* @param url - URL to fetch
* @param init - Fetch options
* @returns Response
* @throws NetworkError on network issues
* @throws PaymentError on non-ok responses
*/
async function fetchSafe (url: string | URL, init?: RequestInit): Promise<Response> {
let response
try {
response = await fetch(url, init)
} catch (err: any) {
throw new NetworkError(`Network error: ${String(err)}`)
}
if (!response.ok) {
const text = await response.text()
try {
const error = JSON.parse(text)
throw new PaymentError(error.error ?? text)
} catch {
throw new PaymentError(`Payment service error: ${response.status} ${text}`)
}
}
return response
}
+30
View File
@@ -0,0 +1,30 @@
//
// 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.
//
/** Error for payment service errors */
export class PaymentError extends Error {
constructor (message: string) {
super(message)
this.name = 'PaymentError'
}
}
/** Error for network/connectivity issues */
export class NetworkError extends Error {
constructor (message: string) {
super(message)
this.name = 'NetworkError'
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './client'
export * from './types'
export * from './error'
+84
View File
@@ -0,0 +1,84 @@
//
// 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.
//
import type { AccountUuid, WorkspaceUuid } from '@hcengineering/core'
export enum SubscriptionType {
Tier = 'tier', // Main workspace tier (free, starter, pro, enterprise)
Support = 'support' // Voluntary support/donation subscription
}
export enum SubscriptionStatus {
Active = 'active', // Subscription is active and paid
Trialing = 'trialing', // In trial period (free usage)
PastDue = 'past_due', // Payment failed but subscription not yet canceled
Canceled = 'canceled', // Subscription was canceled by user or admin
Paused = 'paused', // Subscription is temporarily paused (some providers support this)
Expired = 'expired' // Subscription or trial has expired
}
/**
* Subscription request parameters
* Used when creating a new subscription
*/
export interface SubscribeRequest {
type: SubscriptionType
plan: string // Plan identifier
customerEmail?: string // Optional customer email
customerName?: string // Optional customer name
}
/**
* Checkout creation response
* Contains checkout details for payment
*/
export interface CheckoutResponse {
checkoutId: string // Checkout session ID
checkoutUrl: string // URL to redirect user to for payment
}
/**
* Subscription data for checkout status
* Matches @hcengineering/account-client Subscription type
* @see @hcengineering/account-client
*/
export interface SubscriptionData {
id: string // Internal unique subscription ID
workspaceUuid: WorkspaceUuid
accountUuid: AccountUuid
provider: string
providerSubscriptionId: string
providerCheckoutId?: string
type: SubscriptionType
status: SubscriptionStatus
plan: string
amount?: number
periodStart?: number
periodEnd?: number
trialEnd?: number
canceledAt?: number
providerData?: Record<string, any>
}
/**
* Checkout status response
* Contains information about the checkout and subscription status
*/
export interface CheckoutStatus {
checkoutId: string // Checkout session ID
subscriptionId: string | null // Subscription ID if completed
status: 'pending' | 'completed' // Checkout status
subscription: SubscriptionData | null // Full subscription data if available
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
}
+2 -1
View File
@@ -185,7 +185,8 @@ export default plugin(presentationId, {
MailUrl: '' as Metadata<string>,
PreviewUrl: '' as Metadata<string>,
PulseUrl: '' as Metadata<string>,
HulylakeUrl: '' as Metadata<string>
HulylakeUrl: '' as Metadata<string>,
PaymentUrl: '' as Metadata<string>
},
status: {
FileTooLarge: '' as StatusCode
+1 -1
View File
@@ -52,7 +52,7 @@
"@hcengineering/panel": "^0.7.0",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/integration-client": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/setting-resources": "^0.7.0",
"@hcengineering/view-resources": "^0.7.0"
}
+3
View File
@@ -2,4 +2,7 @@
<symbol id="billing" viewBox="0 0 16 16">
<path d="M5.44 7.47h5.26v1.25H5.44zm0 2.36h5.26v1.25H5.44zm0-4.76h5.26v1.25H5.44z"/><path d="M11.34 1 9.64.28 8.08 1 6.41.28 4.84 1 2.46 0v16l2.38-1 1.57.69L8.08 15l1.56.69 1.7-.69 2.2 1V0zm.94 13.11-.92-.41-1.69.69-1.57-.72-1.68.69-1.55-.69-1.15.47V1.86l1.15.47 1.55-.69 1.68.69 1.57-.69 1.69.69.92-.41z"/>
</symbol>
<symbol id="subscriptions" viewBox="0 0 16 16">
<path d="M13.5 3h-11A1.5 1.5 0 0 0 1 4.5v7A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 13.5 3zm.25 8.5a.25.25 0 0 1-.25.25h-11a.25.25 0 0 1-.25-.25V8h11.5zm0-4.75H2.25V4.5a.25.25 0 0 1 .25-.25h11a.25.25 0 0 1 .25.25zM4 9.5h3v1.25H4zm4.5 0H11v1.25H8.5z"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 439 B

After

Width:  |  Height:  |  Size: 779 B

+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Fakturace",
"Subscriptions": "Předplatné",
"AllPlans": "Všechny tarify",
"ActivePlan": "Aktivní tarif",
"ResourceUsage": "Využití zdrojů",
"DriveSize": "Velikost souborů",
"DriveCount": "Počet souborů",
"OfficeSessionsDuration": "Doba schůzek",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Doba záznamu",
"AI": "AI",
"TranscriptionTime": "Doba přepisu",
"TotalTokens": "Celkem tokenů"
"TotalTokens": "Celkem tokenů",
"Tier": "Tarif",
"StorageLimit": "{limit}GB úložiště",
"TrafficLimit": "{limit}GB video/audio provozu",
"Common": "Common",
"CommonDescription": "Pro jednotlivce a týmy začínající s Huly.",
"Rare": "Rare",
"RareDescription": "Pro jednotlivé kreativce, freelancery a mikro-agentury.",
"Epic": "Epic",
"EpicDescription": "Pro profesionální kreativní společnosti a malé firmy.",
"Legendary": "Legendary",
"LegendaryDescription": "Nejlepší pro velké týmy, které potřebují maximální možnosti.",
"UnlimitedUsers": "Neomezení uživatelé",
"UnlimitedObjects": "Neomezené Huly objekty",
"Upgrade": "Upgradovat",
"Subscribe": "Přihlásit se",
"Monthly": "Měsíčně",
"Acitve": "Aktivní",
"NoActivePlan": "Žádný aktivní plán",
"SelectPlanToBegin": "Chcete-li začít, vyberte plán níže",
"SubscriptionEnds": "Do {date}",
"SubscriptionRenews": "Obnovení: {date}",
"SubscriptionValidUntil": "Platné do {date}",
"ProcessingPayment": "Zpracování platby...",
"Downgrade": "Snížit",
"CancelSubscription": "Zrušit předplatné",
"ConfirmUpgrade": "Upgradovat předplatné?",
"ConfirmDowngrade": "Snížit předplatné?",
"ConfirmCancel": "Zrušit předplatné?",
"UpgradeDescription": "Bude vám účtováno až ${amount} okamžitě. Přesná částka závisí na vašem aktuálním fakturačním období.",
"DowngradeDescription": "Obdržíte kredit až ${amount} za zbývající čas předplatného. Snížení vstoupí v platnost okamžitě.",
"CancelDescription": "Vaše předplatné zůstane aktivní do konce období fakturace.",
"UncancelSubscription": "Reaktivovat předplatné",
"ConfirmUncancel": "Reaktivovat předplatné?",
"UncancelDescription": "Vaše předplatné bude reaktivováno a bude pokračovat od konce aktuálního období fakturace.",
"PriceDifference": "Rozdíl v ceně: {amount}",
"DialogCancel": "Zrušit",
"DialogConfirm": "Potvrdit"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Abrechnung",
"Subscriptions": "Abonnements",
"AllPlans": "Alle Pläne",
"ActivePlan": "Aktiver Plan",
"ResourceUsage": "Ressourcennutzung",
"DriveSize": "Dateigröße",
"DriveCount": "Dateianzahl",
"OfficeSessionsDuration": "Besprechungsdauer",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Aufzeichnungsdauer",
"AI": "AI",
"TranscriptionTime": "Transkriptionszeit",
"TotalTokens": "Gesamte Tokens"
"TotalTokens": "Gesamte Tokens",
"Tier": "Tarif",
"StorageLimit": "{limit} {unit} Speicher",
"TrafficLimit": "{limit} {unit} Video/Audio-Traffic",
"Common": "Common",
"CommonDescription": "Für Einzelpersonen und Teams, die mit Huly beginnen.",
"Rare": "Rare",
"RareDescription": "Für kreative Einzelpersonen, Freelancer und Mikro-Agenturen.",
"Epic": "Epic",
"EpicDescription": "Für professionelle Kreativunternehmen und kleine Unternehmen.",
"Legendary": "Legendary",
"LegendaryDescription": "Am besten für große Teams, die maximale Möglichkeiten benötigen.",
"UnlimitedUsers": "Unbegrenzte Benutzer",
"UnlimitedObjects": "Unbegrenzte Huly-Objekte",
"ChangePlan": "Plan ändern",
"Subscribe": "Abonnieren",
"Monthly": "Monatlich",
"Active": "Aktiv",
"NoActivePlan": "Kein aktiver Plan",
"SelectPlanToBegin": "Wählen Sie unten einen Plan aus, um zu beginnen",
"SubscriptionEnds": "Bis {date}",
"SubscriptionRenews": "Nächste Erneuerung: {date}",
"SubscriptionValidUntil": "Gültig bis {date}",
"ProcessingPayment": "Zahlung wird verarbeitet...",
"Downgrade": "Herabstufen",
"CancelSubscription": "Abonnement kündigen",
"ConfirmUpgrade": "Abonnement aktualisieren?",
"ConfirmDowngrade": "Abonnement herabstufen?",
"ConfirmCancel": "Abonnement kündigen?",
"UpgradeDescription": "Ihnen wird sofort bis zu ${amount} berechnet. Der genaue Betrag hängt von Ihrem aktuellen Abrechnungszeitraum ab.",
"DowngradeDescription": "Sie erhalten eine Gutschrift von bis zu ${amount} für die verbleibende Abonnementlaufzeit. Die Herabstufung wird sofort wirksam.",
"CancelDescription": "Ihr Abonnement bleibt bis zum Ende des Abonnementzeitraums aktiv.",
"UncancelSubscription": "Abonnement reaktivieren",
"ConfirmUncancel": "Abonnement reaktivieren?",
"UncancelDescription": "Ihr Abonnement wird reaktiviert und läuft ab dem Ende des aktuellen Abrechnungszeitraums weiter.",
"PriceDifference": "Preisdifferenz: {amount}",
"DialogCancel": "Abbrechen",
"DialogConfirm": "Bestätigen"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Billing",
"Subscriptions": "Subscriptions",
"AllPlans": "All plans",
"ActivePlan": "Active plan",
"ResourceUsage": "Resource usage",
"DriveSize": "Files size",
"DriveCount": "Files count",
"OfficeSessionsDuration": "Meetings time",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Recording time",
"AI": "AI",
"TranscriptionTime": "Transcription time",
"TotalTokens": "Total tokens"
"TotalTokens": "Total tokens",
"Tier": "Tier",
"StorageLimit": "{limit} {unit} storage",
"TrafficLimit": "{limit} {unit} video/audio traffic",
"Common": "Common",
"CommonDescription": "For individuals and teams getting started with Huly.",
"Rare": "Rare",
"RareDescription": "For individual creatives, freelancers, and micro-agencies.",
"Epic": "Epic",
"EpicDescription": "For professional creative companies and small businesses.",
"Legendary": "Legendary",
"LegendaryDescription": "Best for large multiple teams that need maximum capabilities.",
"UnlimitedUsers": "Unlimited users",
"UnlimitedObjects": "Unlimited Huly objects",
"ChangePlan": "Change plan",
"Subscribe": "Subscribe",
"Monthly": "Monthly",
"Active": "Active",
"NoActivePlan": "No active plan",
"SelectPlanToBegin": "Select a plan below to get started",
"SubscriptionEnds": "Until {date}",
"SubscriptionRenews": "Next renewal: {date}",
"SubscriptionValidUntil": "Valid until {date}",
"ProcessingPayment": "Processing payment...",
"Downgrade": "Downgrade",
"CancelSubscription": "Cancel Subscription",
"ConfirmUpgrade": "Upgrade subscription?",
"ConfirmDowngrade": "Downgrade subscription?",
"ConfirmCancel": "Cancel subscription?",
"UpgradeDescription": "You will be charged up to ${amount} immediately. The exact amount depends on your current billing period.",
"DowngradeDescription": "You will receive a credit of up to ${amount} for the remaining subscription time. The downgrade takes effect immediately.",
"CancelDescription": "Your subscription will remain active until the end of the billing period.",
"UncancelSubscription": "Reactivate Subscription",
"ConfirmUncancel": "Reactivate subscription?",
"UncancelDescription": "Your subscription will be reactivated and continue from the end of the current billing period.",
"PriceDifference": "Price difference: {amount}",
"DialogCancel": "Cancel",
"DialogConfirm": "Confirm"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Facturación",
"Subscriptions": "Suscripciones",
"AllPlans": "Todos los planes",
"ActivePlan": "Plan activo",
"ResourceUsage": "Uso de recursos",
"DriveSize": "Tamaño de archivos",
"DriveCount": "Cantidad de archivos",
"OfficeSessionsDuration": "Tiempo de reuniones",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Tiempo de grabación",
"AI": "IA",
"TranscriptionTime": "Tiempo de transcripción",
"TotalTokens": "Total de tokens"
"TotalTokens": "Total de tokens",
"Tier": "Plan",
"StorageLimit": "{limit} {unit} de almacenamiento",
"TrafficLimit": "{limit} {unit} de tráfico de video/audio",
"Common": "Common",
"CommonDescription": "Para individuos y equipos que comienzan con Huly.",
"Rare": "Rare",
"RareDescription": "Para creativos individuales, freelancers y micro-agencias.",
"Epic": "Epic",
"EpicDescription": "Para empresas creativas profesionales y pequeñas empresas.",
"Legendary": "Legendary",
"LegendaryDescription": "Lo mejor para equipos grandes que necesitan capacidades máximas.",
"UnlimitedUsers": "Usuarios ilimitados",
"UnlimitedObjects": "Objetos Huly ilimitados",
"ChangePlan": "Cambiar plan",
"Subscribe": "Suscribirse",
"Monthly": "Mensual",
"Active": "Activo",
"SubscriptionEnds": "Hasta {date}",
"SubscriptionRenews": "Próxima renovación: {date}",
"SubscriptionValidUntil": "Válido hasta {date}",
"ProcessingPayment": "Procesando pago...",
"Downgrade": "Reducir",
"CancelSubscription": "Cancelar suscripción",
"ConfirmUpgrade": "¿Mejorar suscripción?",
"ConfirmDowngrade": "¿Reducir suscripción?",
"ConfirmCancel": "¿Cancelar suscripción?",
"UpgradeDescription": "Se le cobrará hasta ${amount} inmediatamente. La cantidad exacta depende de su período de facturación actual.",
"DowngradeDescription": "Recibirá un crédito de hasta ${amount} por el tiempo de suscripción restante. La reducción entra en vigor inmediatamente.",
"CancelDescription": "Su suscripción permanecerá activa hasta el final del período de facturación.",
"UncancelSubscription": "Reactivar suscripción",
"ConfirmUncancel": "¿Reactivar suscripción?",
"UncancelDescription": "Su suscripción se reactivará y continuará desde el final del período de facturación actual.",
"PriceDifference": "Diferencia de precio: {amount}",
"DialogCancel": "Cancelar",
"DialogConfirm": "Confirmar",
"NoActivePlan": "Sin plan activo",
"SelectPlanToBegin": "Selecciona un plan a continuación para comenzar"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Facturation",
"Subscriptions": "Abonnements",
"AllPlans": "Tous les plans",
"ActivePlan": "Plan actif",
"ResourceUsage": "Utilisation des ressources",
"DriveSize": "Taille des fichiers",
"DriveCount": "Nombre de fichiers",
"OfficeSessionsDuration": "Durée des réunions",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Durée d'enregistrement",
"AI": "IA",
"TranscriptionTime": "Durée de transcription",
"TotalTokens": "Nombre total de jetons"
"TotalTokens": "Nombre total de jetons",
"Tier": "Plan",
"StorageLimit": "{limit} {unit} de stockage",
"TrafficLimit": "{limit} {unit} de trafic vidéo/audio",
"Common": "Common",
"CommonDescription": "Pour les particuliers et les équipes qui débutent avec Huly.",
"Rare": "Rare",
"RareDescription": "Pour les créateurs individuels, freelancers et micro-agences.",
"Epic": "Epic",
"EpicDescription": "Pour les entreprises créatives professionnelles et les petites entreprises.",
"Legendary": "Legendary",
"LegendaryDescription": "Idéal pour les grandes équipes qui nécessitent des capacités maximales.",
"UnlimitedUsers": "Utilisateurs illimités",
"UnlimitedObjects": "Objets Huly illimités",
"ChangePlan": "Changer de plan",
"Subscribe": "S'abonner",
"Monthly": "Mensuel",
"Active": "Actif",
"NoActivePlan": "Aucun plan actif",
"SelectPlanToBegin": "Sélectionnez un plan ci-dessous pour commencer",
"SubscriptionEnds": "Jusqu'au {date}",
"SubscriptionRenews": "Prochain renouvellement: {date}",
"SubscriptionValidUntil": "Valide jusqu'au {date}",
"ProcessingPayment": "Traitement du paiement...",
"Downgrade": "Rétrograder",
"CancelSubscription": "Annuler l'abonnement",
"ConfirmUpgrade": "Mettre à niveau l'abonnement?",
"ConfirmDowngrade": "Rétrograder l'abonnement?",
"ConfirmCancel": "Annuler l'abonnement?",
"UpgradeDescription": "Vous serez facturé jusqu'à ${amount} immédiatement. Le montant exact dépend de votre période de facturation actuelle.",
"DowngradeDescription": "Vous recevrez un crédit jusqu'à ${amount} pour le temps d'abonnement restant. La rétrogradation prend effet immédiatement.",
"CancelDescription": "Votre abonnement reste actif jusqu'à la fin de la période de facturation.",
"UncancelSubscription": "Réactiver l'abonnement",
"ConfirmUncancel": "Réactiver l'abonnement?",
"UncancelDescription": "Votre abonnement sera réactivé et se poursuivra à partir de la fin de la période de facturation actuelle.",
"PriceDifference": "Différence de prix: {amount}",
"DialogCancel": "Annuler",
"DialogConfirm": "Confirmer"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Fatturazione",
"Subscriptions": "Abbonamenti",
"AllPlans": "Tutti i piani",
"ActivePlan": "Piano attivo",
"ResourceUsage": "Utilizzo risorse",
"DriveSize": "Dimensione file",
"DriveCount": "Conteggio file",
"OfficeSessionsDuration": "Durata riunioni",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Durata registrazione",
"AI": "AI",
"TranscriptionTime": "Tempo di trascrizione",
"TotalTokens": "Totale token"
"TotalTokens": "Totale token",
"Tier": "Piano",
"StorageLimit": "{limit} {unit} di archiviazione",
"TrafficLimit": "{limit} {unit} di traffico video/audio",
"Common": "Common",
"CommonDescription": "Per singoli e team che iniziano con Huly.",
"Rare": "Rare",
"RareDescription": "Per creativi individuali, freelancer e micro-agenzie.",
"Epic": "Epic",
"EpicDescription": "Per aziende creative professionali e piccole imprese.",
"Legendary": "Legendary",
"LegendaryDescription": "Migliore per grandi team che necessitano di capacità massime.",
"UnlimitedUsers": "Utenti illimitati",
"UnlimitedObjects": "Oggetti Huly illimitati",
"ChangePlan": "Cambia piano",
"Subscribe": "Sottoscrivi",
"Monthly": "Mensile",
"Active": "Attivo",
"NoActivePlan": "Nessun piano attivo",
"SelectPlanToBegin": "Seleziona un piano di seguito per iniziare",
"SubscriptionEnds": "Fino al {date}",
"SubscriptionRenews": "Prossimo rinnovo: {date}",
"SubscriptionValidUntil": "Valido fino al {date}",
"ProcessingPayment": "Elaborazione pagamento...",
"Downgrade": "Downgrade",
"CancelSubscription": "Annulla abbonamento",
"ConfirmUpgrade": "Aggiornare l'abbonamento?",
"ConfirmDowngrade": "Eseguire il downgrade dell'abbonamento?",
"ConfirmCancel": "Annullare l'abbonamento?",
"UpgradeDescription": "Ti verrà addebitato fino a ${amount} immediatamente. L'importo esatto dipende dal tuo periodo di fatturazione attuale.",
"DowngradeDescription": "Riceverai un credito fino a ${amount} per il tempo di abbonamento rimanente. Il downgrade ha effetto immediato.",
"CancelDescription": "Il tuo abbonamento rimane attivo fino alla fine del periodo di fatturazione.",
"UncancelSubscription": "Riattiva abbonamento",
"ConfirmUncancel": "Riattivare l'abbonamento?",
"UncancelDescription": "Il tuo abbonamento verrà riattivato e continuerà dalla fine del periodo di fatturazione attuale.",
"PriceDifference": "Differenza di prezzo: {amount}",
"DialogCancel": "Annulla",
"DialogConfirm": "Conferma"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "請求",
"Subscriptions": "サブスクリプション",
"AllPlans": "すべてのプラン",
"ActivePlan": "アクティブなプラン",
"ResourceUsage": "リソース使用状況",
"DriveSize": "ファイルサイズ",
"DriveCount": "ファイル数",
"OfficeSessionsDuration": "会議時間",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "録画時間",
"AI": "AI",
"TranscriptionTime": "文字起こし時間",
"TotalTokens": "合計トークン"
"TotalTokens": "合計トークン",
"Tier": "プラン",
"StorageLimit": "{limit} {unit} ストレージ",
"TrafficLimit": "{limit} {unit} ビデオ/オーディオトラフィック",
"Common": "Common",
"CommonDescription": "Hulyを始める個人とチーム向け。",
"Rare": "Rare",
"RareDescription": "個人クリエイター、フリーランサー、マイクロエージェンシー向け。",
"Epic": "Epic",
"EpicDescription": "プロのクリエイティブ企業と中小企業向け。",
"Legendary": "Legendary",
"LegendaryDescription": "最大限の機能が必要な大規模チームに最適。",
"UnlimitedUsers": "無制限のユーザー",
"UnlimitedObjects": "無制限のHulyオブジェクト",
"ChangePlan": "プラン変更",
"Subscribe": "購読",
"Monthly": "月額",
"Active": "アクティブ",
"SubscriptionEnds": "{date}まで",
"SubscriptionRenews": "次回更新: {date}",
"SubscriptionValidUntil": "{date}まで有効",
"ProcessingPayment": "お支払い処理中...",
"Downgrade": "ダウングレード",
"CancelSubscription": "サブスクリプションをキャンセル",
"ConfirmUpgrade": "サブスクリプションをアップグレードしますか?",
"ConfirmDowngrade": "サブスクリプションをダウングレードしますか?",
"ConfirmCancel": "サブスクリプションをキャンセルしますか?",
"UpgradeDescription": "最大 ${amount} が即座に請求されます。正確な金額は、現在の請求期間によって異なります。",
"DowngradeDescription": "残りのサブスクリプション時間分の最大 ${amount} のクレジットを受け取ります。ダウングレードは即座に有効になります。",
"CancelDescription": "サブスクリプションは請求期間の終了まで有効なままです。",
"UncancelSubscription": "サブスクリプションを再度有効化",
"ConfirmUncancel": "サブスクリプションを再度有効化しますか?",
"UncancelDescription": "サブスクリプションが再度有効化され、現在の請求期間の終了から継続します。",
"PriceDifference": "価格差: {amount}",
"DialogCancel": "キャンセル",
"DialogConfirm": "確認",
"NoActivePlan": "アクティブなプランなし",
"SelectPlanToBegin": "下記からプランを選択して開始してください"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Faturamento",
"Subscriptions": "Assinaturas",
"AllPlans": "Todos os planos",
"ActivePlan": "Plano ativo",
"ResourceUsage": "Uso de recursos",
"DriveSize": "Tamanho dos arquivos",
"DriveCount": "Quantidade de arquivos",
"OfficeSessionsDuration": "Tempo de reuniões",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Tempo de gravação",
"AI": "IA",
"TranscriptionTime": "Tempo de transcrição",
"TotalTokens": "Total de tokens"
"TotalTokens": "Total de tokens",
"Tier": "Plano",
"StorageLimit": "{limit} {unit} de armazenamento",
"TrafficLimit": "{limit} {unit} de tráfego de vídeo/áudio",
"Common": "Common",
"CommonDescription": "Para indivíduos e equipes começando com Huly.",
"Rare": "Rare",
"RareDescription": "Para criativos individuais, freelancers e micro-agências.",
"Epic": "Epic",
"EpicDescription": "Para empresas criativas profissionais e pequenas empresas.",
"Legendary": "Legendary",
"LegendaryDescription": "Melhor para grandes equipes que precisam de capacidades máximas.",
"UnlimitedUsers": "Usuários ilimitados",
"UnlimitedObjects": "Objetos Huly ilimitados",
"ChangePlan": "Alterar plano",
"Subscribe": "Assinar",
"Monthly": "Mensal",
"Active": "Ativo",
"NoActivePlan": "Nenhum plano ativo",
"SelectPlanToBegin": "Selecione um plano abaixo para começar",
"SubscriptionEnds": "Até {date}",
"SubscriptionRenews": "Próxima renovação: {date}",
"SubscriptionValidUntil": "Válido até {date}",
"ProcessingPayment": "Processando pagamento...",
"Downgrade": "Fazer downgrade",
"CancelSubscription": "Cancelar assinatura",
"ConfirmUpgrade": "Atualizar assinatura?",
"ConfirmDowngrade": "Fazer downgrade da assinatura?",
"ConfirmCancel": "Cancelar assinatura?",
"UpgradeDescription": "Você será cobrado até ${amount} imediatamente. O valor exato depende de seu período de faturamento atual.",
"DowngradeDescription": "Você receberá um crédito de até ${amount} pelo tempo de assinatura restante. O downgrade entra em vigor imediatamente.",
"CancelDescription": "Sua assinatura permanecerá ativa até o final do período de faturamento.",
"UncancelSubscription": "Reativar assinatura",
"ConfirmUncancel": "Reativar assinatura?",
"UncancelDescription": "Sua assinatura será reativada e continuará a partir do final do período de faturamento atual.",
"PriceDifference": "Diferença de preço: {amount}",
"DialogCancel": "Cancelar",
"DialogConfirm": "Confirmar"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Биллинг",
"Subscriptions": "Подписки",
"AllPlans": "Все тарифы",
"ActivePlan": "Активный тариф",
"ResourceUsage": "Использование ресурсов",
"DriveSize": "Размер файлов",
"DriveCount": "Количество файлов",
"OfficeSessionsDuration": "Время встреч",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Время записи",
"AI": "AI",
"TranscriptionTime": "Время транскрипции",
"TotalTokens": "Всего токенов"
"TotalTokens": "Всего токенов",
"Tier": "Тариф",
"StorageLimit": "{limit} {unit} хранилища",
"TrafficLimit": "{limit} {unit} видео/аудио трафика",
"Common": "Common",
"CommonDescription": "Для частных лиц и команд, начинающих работу с Huly.",
"Rare": "Rare",
"RareDescription": "Для индивидуальных творческих работников, фрилансеров и микро-агентств.",
"Epic": "Epic",
"EpicDescription": "Для профессиональных творческих компаний и малого бизнеса.",
"Legendary": "Legendary",
"LegendaryDescription": "Лучший вариант для больших команд, которым нужны максимальные возможности.",
"UnlimitedUsers": "Неограниченное количество пользователей",
"UnlimitedObjects": "Неограниченное количество объектов Huly",
"ChangePlan": "Сменить план",
"Subscribe": "Подписаться",
"Monthly": "В месяц",
"Active": "Активен",
"NoActivePlan": "Нет активного плана",
"SelectPlanToBegin": "Выберите план ниже, чтобы начать",
"SubscriptionEnds": "До {date}",
"SubscriptionRenews": "Следующее обновление: {date}",
"SubscriptionValidUntil": "Действительно до {date}",
"ProcessingPayment": "Обработка платежа...",
"Downgrade": "Понизить",
"CancelSubscription": "Отменить подписку",
"ConfirmUpgrade": "Обновить подписку?",
"ConfirmDowngrade": "Понизить подписку?",
"ConfirmCancel": "Отменить подписку?",
"UpgradeDescription": "С вас будет взято до ${amount} немедленно. Точная сумма зависит от вашего текущего периода выставления счета.",
"DowngradeDescription": "Вы получите кредит до ${amount} за оставшееся время подписки. Понижение вступает в силу немедленно.",
"CancelDescription": "Ваша подписка останется активной до конца периода выставления счета.",
"UncancelSubscription": "Переактивировать подписку",
"ConfirmUncancel": "Переактивировать подписку?",
"UncancelDescription": "Ваша подписка будет переактивирована и продолжится с конца текущего периода выставления счета.",
"PriceDifference": "Разница в цене: {amount}",
"DialogCancel": "Отменить",
"DialogConfirm": "Подтвердить"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "Faturalama",
"Subscriptions": "Abonelikler",
"AllPlans": "Tüm planlar",
"ActivePlan": "Aktif plan",
"ResourceUsage": "Kaynak kullanımı",
"DriveSize": "Dosya boyutu",
"DriveCount": "Dosya sayısı",
"OfficeSessionsDuration": "Toplantı süresi",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "Kayıt süresi",
"AI": "AI",
"TranscriptionTime": "Transkripsiyon süresi",
"TotalTokens": "Toplam belirteçler"
"TotalTokens": "Toplam belirteçler",
"Tier": "Plan",
"StorageLimit": "{limit} {unit} depolama",
"TrafficLimit": "{limit} {unit} video/ses trafiği",
"Common": "Common",
"CommonDescription": "Huly ile başlayan bireyler ve ekipler için.",
"Rare": "Rare",
"RareDescription": "Bireysel yaratıcılar, serbest çalışanlar ve mikro ajanslar için.",
"Epic": "Epic",
"EpicDescription": "Profesyonel yaratıcı şirketler ve küçük işletmeler için.",
"Legendary": "Legendary",
"LegendaryDescription": "Maksimum yeteneklere ihtiyaç duyan büyük ekipler için en iyisi.",
"UnlimitedUsers": "Sınırsız kullanıcı",
"UnlimitedObjects": "Sınırsız Huly nesneleri",
"ChangePlan": "Plan değiştir",
"Subscribe": "Abone ol",
"Monthly": "Aylık",
"Active": "Aktif",
"NoActivePlan": "Etkin plan yok",
"SelectPlanToBegin": "Başlamak için aşağıdan bir plan seçin",
"SubscriptionEnds": "{date} tarihine kadar",
"SubscriptionRenews": "Sonraki yenileme: {date}",
"SubscriptionValidUntil": "{date} tarihine kadar geçerli",
"ProcessingPayment": "Ödeme işleniyor...",
"Downgrade": "Düşür",
"CancelSubscription": "Aboneliği İptal Et",
"ConfirmUpgrade": "Aboneliği yükselt?",
"ConfirmDowngrade": "Aboneliği düşür?",
"ConfirmCancel": "Aboneliği iptal et?",
"UpgradeDescription": "Size hemen ${amount} kadar tahsil edilecektir. Tam tutar, mevcut faturalandırma döneminizie bağlıdır.",
"DowngradeDescription": "Kalan abonelik süresi için ${amount} kadar kredi alacaksınız. Düşürme hemen etkili olur.",
"CancelDescription": "Aboneliğiniz faturalandırma döneminin sonuna kadar etkin kalacaktır.",
"UncancelSubscription": "Aboneliği Yeniden Etkinleştir",
"ConfirmUncancel": "Aboneliği yeniden etkinleştir?",
"UncancelDescription": "Aboneliğiniz yeniden etkinleştirilecek ve geçerli faturalandırma döneminin sonundan itibaren devam edecektir.",
"PriceDifference": "Fiyat farkı: {amount}",
"DialogCancel": "İptal",
"DialogConfirm": "Onayla"
}
}
+42 -1
View File
@@ -1,6 +1,10 @@
{
"string": {
"Billing": "计费",
"Subscriptions": "订阅",
"AllPlans": "所有计划",
"ActivePlan": "当前计划",
"ResourceUsage": "资源使用情况",
"DriveSize": "文件大小",
"DriveCount": "文件数量",
"OfficeSessionsDuration": "会议时间",
@@ -8,6 +12,43 @@
"OfficeEgressDuration": "录制时间",
"AI": "AI",
"TranscriptionTime": "转录时间",
"TotalTokens": "总令牌数"
"TotalTokens": "总令牌数",
"Tier": "计划",
"StorageLimit": "{limit} {unit} 存储空间",
"TrafficLimit": "{limit} {unit} 视频/音频流量",
"Common": "Common",
"CommonDescription": "适合刚开始使用Huly的个人和团队。",
"Rare": "Rare",
"RareDescription": "适合个人创作者、自由职业者和微型机构。",
"Epic": "Epic",
"EpicDescription": "适合专业创意公司和小型企业。",
"Legendary": "Legendary",
"LegendaryDescription": "最适合需要最大功能的大型团队。",
"UnlimitedUsers": "无限用户",
"UnlimitedObjects": "无限Huly对象",
"ChangePlan": "更改计划",
"Subscribe": "订阅",
"Monthly": "每月",
"Active": "活跃",
"SubscriptionEnds": "截止至{date}",
"SubscriptionRenews": "下次续期: {date}",
"SubscriptionValidUntil": "有效期至{date}",
"ProcessingPayment": "正在处理付款...",
"Downgrade": "降级",
"CancelSubscription": "取消订阅",
"ConfirmUpgrade": "升级订阅?",
"ConfirmDowngrade": "降级订阅?",
"ConfirmCancel": "取消订阅?",
"UpgradeDescription": "将立即向您收取最多 ${amount}。具体金额取决于您当前的计费周期。",
"DowngradeDescription": "您将获得最多 ${amount} 的剩余订阅时间积分。降级立即生效。",
"CancelDescription": "您的订阅将在计费周期结束前保持活跃。",
"UncancelSubscription": "重新激活订阅",
"ConfirmUncancel": "重新激活订阅?",
"UncancelDescription": "您的订阅将被重新激活,并从当前计费周期结束时继续。",
"PriceDifference": "价格差异: {amount}",
"DialogCancel": "取消",
"DialogConfirm": "确认",
"NoActivePlan": "无活跃计划",
"SelectPlanToBegin": "选择下面的计划开始使用"
}
}
+2 -1
View File
@@ -17,5 +17,6 @@ import billingPlugin from '@hcengineering/billing'
const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(billingPlugin.icon, {
Billing: `${icons}#billing`
Billing: `${icons}#billing`,
Subscriptions: `${icons}#subscriptions`
})
+4
View File
@@ -42,6 +42,7 @@
"@hcengineering/platform": "^0.7.17",
"@hcengineering/presentation": "^0.7.0",
"@hcengineering/theme": "^0.7.0",
"@hcengineering/core": "^0.7.18",
"@hcengineering/ui": "^0.7.0",
"@hcengineering/billing": "^0.7.0",
"@hcengineering/view": "^0.7.0",
@@ -49,6 +50,9 @@
"@hcengineering/billing-client": "^0.7.0",
"@hcengineering/drive": "^0.7.0",
"@hcengineering/love": "^0.7.0",
"@hcengineering/login": "^0.7.0",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/payment-client": "^0.7.0",
"filesize": "^8.0.3",
"svelte": "^4.2.20"
}
@@ -0,0 +1,139 @@
<!--
// 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 { Loading, Scroller, formatDuration, formatNumberCompact, themeStore } from '@hcengineering/ui'
import { getCurrentWorkspaceUuid } from '@hcengineering/presentation'
import billingPlugin from '@hcengineering/billing'
import love from '@hcengineering/love'
import view from '@hcengineering/view'
import filesize from 'filesize'
import StatsCard from './StatsCard.svelte'
import drivePlugin from '@hcengineering/drive'
import Category from './Category.svelte'
import ChartCard from './ChartCard.svelte'
import { getBillingClient } from '../utils'
const billingClient = getBillingClient()
let totalDatalakeSize = 0
let totalDatalakeCount = 0
let totalSessionsDuration = 0
let totalSessionsBandwidth = 0
let totalEgressDuration = 0
let sessionsDurationByDay: { date: number, value: number }[] = []
let sessionsBandwidthByDay: { date: number, value: number }[] = []
let egressDurationByDay: { date: number, value: number }[] = []
let totalTranscriptDuration = 0
let totalTokensCount = 0
async function loadBillingData (): Promise<void> {
if (billingClient == null) return
const billingStats = await billingClient.getBillingStats(getCurrentWorkspaceUuid())
totalDatalakeSize = billingStats.datalakeStats.size
totalDatalakeCount = billingStats.datalakeStats.count
totalSessionsDuration = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.minutes, 0) * 60000
totalSessionsBandwidth = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.bandwidth, 0)
totalEgressDuration = billingStats.liveKitStats.egress.reduce((sum, e) => sum + e.minutes, 0) * 60000
totalTranscriptDuration = billingStats.aiStats.transcript.totalDurationSeconds * 1000
totalTokensCount = billingStats.aiStats.tokens.reduce((sum, s) => sum + s.totalTokens, 0)
sessionsDurationByDay = billingStats.liveKitStats.sessions.map((s) => {
const date = new Date(Date.parse(s.day))
date.setHours(0, 0, 0, 0)
return { date: date.getTime(), value: s.minutes * 60000 }
})
sessionsBandwidthByDay = billingStats.liveKitStats.sessions.map((s) => {
const date = new Date(Date.parse(s.day))
date.setHours(0, 0, 0, 0)
return { date: date.getTime(), value: s.bandwidth }
})
egressDurationByDay = billingStats.liveKitStats.egress.map((s) => {
const date = new Date(Date.parse(s.day))
date.setHours(0, 0, 0, 0)
return { date: date.getTime(), value: s.minutes * 60000 }
})
}
</script>
{#await loadBillingData()}
<Loading />
{:then _}
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content gapV-8">
<Category icon={drivePlugin.icon.DriveApplication} label={drivePlugin.string.Drive}>
<div class="row">
<StatsCard label={billingPlugin.string.DriveSize} text={filesize(totalDatalakeSize, { spacer: ' ' })} />
<StatsCard label={billingPlugin.string.DriveCount} text={totalDatalakeCount.toString()} />
</div>
</Category>
<Category icon={view.icon.AiStar} label={billingPlugin.string.AI}>
<div class="row">
<StatsCard
label={billingPlugin.string.TranscriptionTime}
text={formatDuration(totalTranscriptDuration, $themeStore.language)}
/>
<StatsCard label={billingPlugin.string.TotalTokens} text={formatNumberCompact(totalTokensCount)} />
</div>
</Category>
<Category icon={love.icon.Love} label={love.string.Office}>
<div class="row">
<StatsCard
label={billingPlugin.string.OfficeSessionsDuration}
text={formatDuration(totalSessionsDuration, $themeStore.language)}
/>
<StatsCard
label={billingPlugin.string.OfficeSessionsBandwidth}
text={filesize(totalSessionsBandwidth, { spacer: ' ' })}
/>
<StatsCard
label={billingPlugin.string.OfficeEgressDuration}
text={formatDuration(totalEgressDuration, $themeStore.language)}
/>
</div>
<div class="row">
<ChartCard
label={billingPlugin.string.OfficeSessionsDuration}
valueFormatter={(v) => formatDuration(v, $themeStore.language)}
data={sessionsDurationByDay}
/>
</div>
<div class="row">
<ChartCard
label={billingPlugin.string.OfficeSessionsBandwidth}
valueFormatter={(v) => Promise.resolve(filesize(v, { spacer: ' ' }))}
data={sessionsBandwidthByDay}
/>
</div>
<div class="row">
<ChartCard
label={billingPlugin.string.OfficeEgressDuration}
valueFormatter={(v) => formatDuration(v, $themeStore.language)}
data={egressDurationByDay}
/>
</div>
</Category>
</div>
</Scroller>
{/await}
<style lang="scss">
.row {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
</style>
@@ -13,143 +13,104 @@
// limitations under the License.
-->
<script lang="ts">
import { type Asset, type IntlString, getMetadata } from '@hcengineering/platform'
import {
AnySvelteComponent,
Breadcrumb,
Component,
Header,
Loading,
Location,
NavItem,
Scroller,
formatDuration,
themeStore,
formatNumberCompact
Separator,
defineSeparators,
getCurrentResolvedLocation,
navigate,
resolvedLocationStore,
twoPanelsSeparators
} from '@hcengineering/ui'
import { getCurrentWorkspaceUuid } from '@hcengineering/presentation'
import billingPlugin from '@hcengineering/billing'
import filesize from 'filesize'
import love from '@hcengineering/love'
import drivePlugin from '@hcengineering/drive'
import view from '@hcengineering/view'
import presentation from '@hcengineering/presentation'
import { onDestroy } from 'svelte'
import { getBillingClient } from '../utils'
import StatsCard from './StatsCard.svelte'
import Category from './Category.svelte'
import ChartCard from './ChartCard.svelte'
import ResourceUsage from './ResourceUsage.svelte'
import Subscriptions from './Subscriptions.svelte'
const billingClient = getBillingClient()
import plugin from '../plugin'
let totalDatalakeSize = 0
let totalDatalakeCount = 0
let totalSessionsDuration = 0
let totalSessionsBandwidth = 0
let totalEgressDuration = 0
let sessionsDurationByDay: { date: number, value: number }[] = []
let sessionsBandwidthByDay: { date: number, value: number }[] = []
let egressDurationByDay: { date: number, value: number }[] = []
let totalTranscriptDuration = 0
let totalTokensCount = 0
async function loadBillingData (): Promise<void> {
if (billingClient == null) return
const billingStats = await billingClient.getBillingStats(getCurrentWorkspaceUuid())
totalDatalakeSize = billingStats.datalakeStats.size
totalDatalakeCount = billingStats.datalakeStats.count
totalSessionsDuration = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.minutes, 0) * 60000
totalSessionsBandwidth = billingStats.liveKitStats.sessions.reduce((sum, s) => sum + s.bandwidth, 0)
totalEgressDuration = billingStats.liveKitStats.egress.reduce((sum, e) => sum + e.minutes, 0) * 60000
totalTranscriptDuration = billingStats.aiStats.transcript.totalDurationSeconds * 1000
totalTokensCount = billingStats.aiStats.tokens.reduce((sum, s) => sum + s.totalTokens, 0)
sessionsDurationByDay = billingStats.liveKitStats.sessions.map((s) => {
const date = new Date(Date.parse(s.day))
date.setHours(0, 0, 0, 0)
return { date: date.getTime(), value: s.minutes * 60000 }
})
sessionsBandwidthByDay = billingStats.liveKitStats.sessions.map((s) => {
const date = new Date(Date.parse(s.day))
date.setHours(0, 0, 0, 0)
return { date: date.getTime(), value: s.bandwidth }
})
egressDurationByDay = billingStats.liveKitStats.egress.map((s) => {
const date = new Date(Date.parse(s.day))
date.setHours(0, 0, 0, 0)
return { date: date.getTime(), value: s.minutes * 60000 }
})
interface SettingGroup {
key: string
icon: Asset
label: IntlString
component: AnySvelteComponent
}
const baseGroups: SettingGroup[] = [
{
key: 'usage',
icon: plugin.icon.Billing,
label: plugin.string.ResourceUsage,
component: ResourceUsage
},
{
key: 'subscriptions',
icon: plugin.icon.Subscriptions,
label: plugin.string.Subscriptions,
component: Subscriptions
}
]
// Only include subscriptions group if payment URL is configured
const paymentUrl = getMetadata(presentation.metadata.PaymentUrl)
const groups =
paymentUrl != null && paymentUrl !== '' ? baseGroups : baseGroups.filter((g) => g.key !== 'subscriptions')
let currentGroupKey = groups[0].key
let currentGroup = groups[0]
const unsubscribeLocation = resolvedLocationStore.subscribe((loc) => {
void (async (loc: Location): Promise<void> => {
const key = loc.path[5]
currentGroup = groups.find((g) => g.key === key) ?? groups[0]
currentGroupKey = currentGroup.key
})(loc)
})
onDestroy(() => {
unsubscribeLocation()
})
defineSeparators('billingSettings', twoPanelsSeparators)
</script>
<div class="hulyComponent">
<Header adaptive={'disabled'}>
<Breadcrumb icon={billingPlugin.icon.Billing} label={billingPlugin.string.Billing} size={'large'} isCurrent />
<Breadcrumb icon={plugin.icon.Billing} label={plugin.string.Billing} size={'large'} isCurrent />
</Header>
<div class="hulyComponent-content__column content">
{#await loadBillingData()}
<Loading />
{:then _}
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content gapV-8">
<Category icon={drivePlugin.icon.DriveApplication} label={drivePlugin.string.Drive}>
<div class="row">
<StatsCard label={billingPlugin.string.DriveSize} text={filesize(totalDatalakeSize, { spacer: ' ' })} />
<StatsCard label={billingPlugin.string.DriveCount} text={totalDatalakeCount.toString()} />
</div>
</Category>
<Category icon={view.icon.AiStar} label={billingPlugin.string.AI}>
<div class="row">
<StatsCard
label={billingPlugin.string.TranscriptionTime}
text={formatDuration(totalTranscriptDuration, $themeStore.language)}
/>
<StatsCard label={billingPlugin.string.TotalTokens} text={formatNumberCompact(totalTokensCount)} />
</div>
</Category>
<Category icon={love.icon.Love} label={love.string.Office}>
<div class="row">
<StatsCard
label={billingPlugin.string.OfficeSessionsDuration}
text={formatDuration(totalSessionsDuration, $themeStore.language)}
/>
<StatsCard
label={billingPlugin.string.OfficeSessionsBandwidth}
text={filesize(totalSessionsBandwidth, { spacer: ' ' })}
/>
<StatsCard
label={billingPlugin.string.OfficeEgressDuration}
text={formatDuration(totalEgressDuration, $themeStore.language)}
/>
</div>
<div class="row">
<ChartCard
label={billingPlugin.string.OfficeSessionsDuration}
valueFormatter={(v) => formatDuration(v, $themeStore.language)}
data={sessionsDurationByDay}
/>
</div>
<div class="row">
<ChartCard
label={billingPlugin.string.OfficeSessionsBandwidth}
valueFormatter={(v) => Promise.resolve(filesize(v, { spacer: ' ' }))}
data={sessionsBandwidthByDay}
/>
</div>
<div class="row">
<ChartCard
label={billingPlugin.string.OfficeEgressDuration}
valueFormatter={(v) => formatDuration(v, $themeStore.language)}
data={egressDurationByDay}
/>
</div>
</Category>
</div>
<div class="hulyComponent-content__container columns">
<div class="hulyComponent-content__column navigation py-2">
<Scroller shrink>
{#each groups as group}
<NavItem
icon={group.icon}
label={group.label}
selected={group.key === currentGroupKey}
on:click={() => {
currentGroupKey = group.key
currentGroup = group
const loc = getCurrentResolvedLocation()
loc.path[5] = group.key
loc.path.length = 6
navigate(loc)
}}
/>
{/each}
</Scroller>
{/await}
</div>
<Separator name="billingSettings" index={0} color={'var(--theme-divider-color)'} />
<div class="hulyComponent-content__column content">
<Component is={currentGroup.component} />
</div>
</div>
</div>
<style lang="scss">
.row {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
</style>
@@ -0,0 +1,603 @@
<!--
// 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, getClient as getAccountClient } from '@hcengineering/account-client'
import { type SubscribeRequest, type CheckoutStatus } from '@hcengineering/payment-client'
import { type Ref, SortingOrder } from '@hcengineering/core'
import login from '@hcengineering/login'
import {
IconCheckmark,
Label,
Loading,
Scroller,
Button,
getPlatformColorByName,
themeStore,
getLocation,
navigate,
showPopup
} from '@hcengineering/ui'
import presentation, { getClient, MessageBox } from '@hcengineering/presentation'
import { Tier } from '@hcengineering/billing'
import { getMetadata } from '@hcengineering/platform'
import { onMount, onDestroy } from 'svelte'
import plugin from '../plugin'
import { getPaymentClient } from '../utils'
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
}, {})
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
$: 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)
}
}
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)
} 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)
} 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)
} finally {
isUncanceling = false
}
}
async function fetchSubscriptions (): Promise<void> {
const accountsUrl = getMetadata(login.metadata.AccountsUrl)
const token = getMetadata(presentation.metadata.Token)
try {
loading = true
const accountClient = getAccountClient(accountsUrl, token)
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)
} finally {
loading = false
}
}
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)
// 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 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>
</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>
<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={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 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;
}
.tier-card-footer {
display: flex;
flex-direction: row-reverse;
margin-top: var(--spacing-3);
height: 2.25rem;
}
.processing {
text-align: center;
}
</style>
+15 -2
View File
@@ -1,8 +1,21 @@
//
// 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.
//
import type { Resources } from '@hcengineering/platform'
import Settings from './components/Settings.svelte'
// export * from './utils'
export default async (): Promise<Resources> => ({
component: {
Settings
+52
View File
@@ -0,0 +1,52 @@
//
// 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.
//
import billing, { billingId } from '@hcengineering/billing'
import { type IntlString, mergeIds } from '@hcengineering/platform'
export default mergeIds(billingId, billing, {
string: {
AllPlans: '' as IntlString,
ActivePlan: '' as IntlString,
ResourceUsage: '' as IntlString,
Subscriptions: '' as IntlString,
UnlimitedUsers: '' as IntlString,
UnlimitedObjects: '' as IntlString,
ChangePlan: '' as IntlString,
Subscribe: '' as IntlString,
Monthly: '' as IntlString,
Active: '' as IntlString,
NoActivePlan: '' as IntlString,
SelectPlanToBegin: '' as IntlString,
SubscriptionEnds: '' as IntlString,
SubscriptionRenews: '' as IntlString,
SubscriptionValidUntil: '' as IntlString,
ProcessingPayment: '' as IntlString,
Downgrade: '' as IntlString,
CancelSubscription: '' as IntlString,
ConfirmUpgrade: '' as IntlString,
ConfirmDowngrade: '' as IntlString,
ConfirmCancel: '' as IntlString,
UpgradeDescription: '' as IntlString,
DowngradeDescription: '' as IntlString,
CancelDescription: '' as IntlString,
UncancelSubscription: '' as IntlString,
ConfirmUncancel: '' as IntlString,
UncancelDescription: '' as IntlString,
PriceDifference: '' as IntlString,
DialogCancel: '' as IntlString,
DialogConfirm: '' as IntlString
}
})
+11
View File
@@ -1,6 +1,7 @@
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { getClient as getBillingClientRaw, type BillingClient } from '@hcengineering/billing-client'
import { getClient as getPaymentClientRaw, type PaymentClient } from '@hcengineering/payment-client'
import billingPlugin from '@hcengineering/billing'
export function getBillingClient (): BillingClient | null {
@@ -11,3 +12,13 @@ export function getBillingClient (): BillingClient | null {
const token = getMetadata(presentation.metadata.Token)
return getBillingClientRaw(billingUrl, token)
}
export function getPaymentClient (): PaymentClient | null {
const paymentUrl = getMetadata(presentation.metadata.PaymentUrl)
if (paymentUrl === undefined || paymentUrl === '') {
return null
}
const token = getMetadata(presentation.metadata.Token)
return getPaymentClientRaw(paymentUrl, token)
}
+2
View File
@@ -14,5 +14,7 @@
//
import { billingId, billingPlugin } from './plugin'
export * from './types'
export { billingId }
export default billingPlugin
+26 -3
View File
@@ -12,13 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Asset, type IntlString, type Metadata, plugin, type Plugin } from '@hcengineering/platform'
import { type Class, type Ref } from '@hcengineering/core'
import { type Asset, type IntlString, type Metadata, plugin, type Plugin } from '@hcengineering/platform'
import { AnyComponent } from '@hcengineering/ui'
import { Tier } from './types'
/** @public */
export const billingId = 'billing' as Plugin
export const billingPlugin = plugin(billingId, {
class: {
Tier: '' as Ref<Class<Tier>>
},
metadata: {
BillingURL: '' as Metadata<string>
},
@@ -34,10 +39,28 @@ export const billingPlugin = plugin(billingId, {
OfficeEgressDuration: '' as IntlString,
AI: '' as IntlString,
TotalTokens: '' as IntlString,
TranscriptionTime: '' as IntlString
TranscriptionTime: '' as IntlString,
Tier: '' as IntlString,
StorageLimit: '' as IntlString,
TrafficLimit: '' as IntlString,
Common: '' as IntlString,
CommonDescription: '' as IntlString,
Rare: '' as IntlString,
RareDescription: '' as IntlString,
Epic: '' as IntlString,
EpicDescription: '' as IntlString,
Legendary: '' as IntlString,
LegendaryDescription: '' as IntlString
},
icon: {
Billing: '' as Asset
Billing: '' as Asset,
Subscriptions: '' as Asset
},
tier: {
Common: '' as Ref<Tier>,
Rare: '' as Ref<Tier>,
Epic: '' as Ref<Tier>,
Legendary: '' as Ref<Tier>
}
})
+29
View File
@@ -0,0 +1,29 @@
//
// 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.
//
import { Doc } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
/** @public */
export interface Tier extends Doc {
label: IntlString
description: IntlString
priceMonthly: number
storageLimitGB: number
trafficLimitGB: number
index: number
color?: string
}
+1 -1
View File
@@ -39,7 +39,7 @@
},
"dependencies": {
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/login": "^0.7.0",
"@hcengineering/core": "^0.7.18",
"@hcengineering/platform": "^0.7.17",
+1 -1
View File
@@ -72,7 +72,7 @@
"@hcengineering/card": "^0.7.0",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/preference": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/chat": "^0.7.0",
"fast-equals": "^5.2.2",
"svelte": "^4.2.20"
+2 -2
View File
@@ -42,8 +42,8 @@
"@hcengineering/client": "^0.7.3",
"@hcengineering/communication-sdk-types": "^0.7.5",
"@hcengineering/communication-types": "^0.7.7",
"@hcengineering/core": "^0.7.10",
"@hcengineering/platform": "^0.7.5",
"@hcengineering/core": "^0.7.18",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/rpc": "^0.7.3",
"snappyjs": "^0.7.0"
},
+2 -2
View File
@@ -37,8 +37,8 @@
"@types/jest": "^29.5.5"
},
"dependencies": {
"@hcengineering/platform": "^0.7.5",
"@hcengineering/core": "^0.7.10"
"@hcengineering/platform": "^0.7.17",
"@hcengineering/core": "^0.7.18"
},
"repository": "https://github.com/hcengineering/platform",
"publishConfig": {
+1 -1
View File
@@ -61,7 +61,7 @@
"@hcengineering/view": "^0.7.0",
"@hcengineering/view-resources": "^0.7.0",
"@hcengineering/workbench": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/achievement": "^0.7.0",
"svelte": "^4.2.20",
"crypto-js": "^4.2.0",
@@ -50,7 +50,7 @@
"@hcengineering/view-resources": "^0.7.0",
"@hcengineering/attachment": "^0.7.0",
"@hcengineering/notification": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/contact-resources": "^0.7.0",
"@hcengineering/tags": "^0.7.0",
@@ -44,7 +44,7 @@
"@hcengineering/view": "^0.7.0",
"@hcengineering/presentation": "^0.7.0",
"@hcengineering/global-profile": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/login": "^0.7.0",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/theme": "^0.7.0"
+1 -1
View File
@@ -62,7 +62,7 @@
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/templates": "^0.7.0",
"@hcengineering/integration-client": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/setting-resources": "^0.7.0"
}
}
+1 -1
View File
@@ -48,7 +48,7 @@
"@hcengineering/presentation": "^0.7.0",
"@hcengineering/guest": "^0.7.0",
"@hcengineering/login": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/view-resources": "^0.7.0",
"@hcengineering/analytics": "^0.7.17",
"fast-copy": "^3.0.2",
+1 -1
View File
@@ -52,7 +52,7 @@
"@hcengineering/panel": "^0.7.0",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/integration-client": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/setting-resources": "^0.7.0",
"@hcengineering/view-resources": "^0.7.0"
}
+1 -1
View File
@@ -49,7 +49,7 @@
"@hcengineering/setting": "^0.7.0",
"@hcengineering/theme": "^0.7.0",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/analytics-providers": "^0.7.0"
}
}
+1 -1
View File
@@ -35,7 +35,7 @@
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@hcengineering/account-client": "^0.7.17"
"@hcengineering/account-client": "^0.7.18"
},
"dependencies": {
"@hcengineering/core": "^0.7.18",
+1 -1
View File
@@ -64,7 +64,7 @@
"@hcengineering/emoji": "^0.7.0",
"@hcengineering/emoji-resources": "^0.7.0",
"@hcengineering/theme": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/hulypulse-client": "^0.7.0",
"@livekit/krisp-noise-filter": "^0.3.0",
"@livekit/track-processors": "^0.5.6",
+1 -1
View File
@@ -42,7 +42,7 @@
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/core": "^0.7.18",
"svelte": "^4.2.20",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/setting": "^0.7.0",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/theme": "^0.7.0",
+1 -1
View File
@@ -41,7 +41,7 @@
"@hcengineering/core": "^0.7.18",
"@hcengineering/templates": "^0.7.0",
"@hcengineering/ui": "^0.7.0",
"@hcengineering/account-client": "^0.7.17"
"@hcengineering/account-client": "^0.7.18"
},
"repository": "https://github.com/hcengineering/platform",
"publishConfig": {
+1 -1
View File
@@ -38,7 +38,7 @@
"svelte-eslint-parser": "^0.33.1"
},
"dependencies": {
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/attachment": "^0.7.0",
"@hcengineering/attachment-resources": "^0.7.0",
+1 -1
View File
@@ -61,7 +61,7 @@
"@hcengineering/communication-types": "^0.7.12",
"fast-copy": "^3.0.2",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/chat": "^0.7.0",
"@hcengineering/communication": "^0.7.0",
"@hcengineering/rating": "^0.7.0",
+1 -1
View File
@@ -48,7 +48,7 @@
"mongodb": "^6.16.0",
"@hcengineering/core": "^0.7.18",
"@hcengineering/account": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"passport-custom": "~1.1.1",
"passport-google-oauth20": "~2.0.0",
"passport-github2": "~0.1.12",
+1 -1
View File
@@ -51,7 +51,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/analytics-service": "^0.7.17",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/core": "^0.7.18",
+1 -1
View File
@@ -91,7 +91,7 @@
"msgpackr-extract": "^3.0.3",
"snappy": "^7.2.2",
"ws": "^8.18.2",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"morgan": "^1.10.0",
"body-parser": "^1.20.3",
"cors": "^2.8.5",
+10
View File
@@ -445,6 +445,11 @@
"projectFolder": "packages/billing-client",
"shouldPublish": true
},
{
"packageName": "@hcengineering/payment-client",
"projectFolder": "packages/payment-client",
"shouldPublish": true
},
{
"packageName": "@hcengineering/prod",
"projectFolder": "dev/prod",
@@ -1976,6 +1981,11 @@
"projectFolder": "services/billing/pod-billing",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-payment",
"projectFolder": "services/payment/pod-payment",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-love",
"projectFolder": "services/love",
+150 -1
View File
@@ -48,7 +48,8 @@ import {
changePassword,
getPerson,
getSocialIds,
createAccessLink
createAccessLink,
getSubscriptions
} from '../operations'
import { accountPlugin } from '../plugin'
@@ -2580,3 +2581,151 @@ describe('account operations', () => {
})
})
})
describe('getSubscriptions', () => {
const mockCtx = {
error: jest.fn(),
info: jest.fn()
} as unknown as MeasureContext
const mockBranding = null
const workspaceUuid = 'test-workspace-uuid' as WorkspaceUuid
const accountUuid = 'test-account-uuid' as AccountUuid
let mockDb: any
beforeEach(() => {
jest.clearAllMocks()
mockDb = {
subscription: {
find: jest.fn()
},
getWorkspaceRole: jest.fn()
}
})
test('should return active subscriptions for workspace owner', async () => {
const mockSubscriptions = [
{
id: 'sub-1',
workspaceUuid,
accountUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123',
type: 'tier',
status: 'active',
plan: 'pro'
}
]
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: accountUuid,
workspace: workspaceUuid,
extra: {}
})
mockDb.getWorkspaceRole.mockResolvedValue(AccountRole.Owner)
mockDb.subscription.find.mockResolvedValue(mockSubscriptions)
const result = await getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', {})
expect(result).toEqual(mockSubscriptions)
expect(mockDb.subscription.find).toHaveBeenCalledWith({ workspaceUuid, status: 'active' })
expect(mockDb.getWorkspaceRole).toHaveBeenCalledWith(accountUuid, workspaceUuid)
})
test('should return all subscriptions when activeOnly=false', async () => {
const mockSubscriptions = [
{
id: 'sub-1',
status: 'active'
},
{
id: 'sub-2',
status: 'canceled'
}
]
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: accountUuid,
workspace: workspaceUuid,
extra: {}
})
mockDb.getWorkspaceRole.mockResolvedValue(AccountRole.Owner)
mockDb.subscription.find.mockResolvedValue(mockSubscriptions)
const result = await getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', { activeOnly: false })
expect(result).toEqual(mockSubscriptions)
expect(mockDb.subscription.find).toHaveBeenCalledWith({ workspaceUuid })
})
test('should allow maintainer to view subscriptions', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: accountUuid,
workspace: workspaceUuid,
extra: {}
})
mockDb.getWorkspaceRole.mockResolvedValue(AccountRole.Maintainer)
mockDb.subscription.find.mockResolvedValue([])
await getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', {})
expect(mockDb.getWorkspaceRole).toHaveBeenCalled()
expect(mockDb.subscription.find).toHaveBeenCalled()
})
test('should reject user without sufficient role', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: accountUuid,
workspace: workspaceUuid,
extra: {}
})
mockDb.getWorkspaceRole.mockResolvedValue(AccountRole.User)
await expect(getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', {})).rejects.toThrow(PlatformError)
})
test('should reject user without workspace membership', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: accountUuid,
workspace: workspaceUuid,
extra: {}
})
mockDb.getWorkspaceRole.mockResolvedValue(null)
await expect(getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', {})).rejects.toThrow(PlatformError)
})
test('should allow service to query any workspace', async () => {
const serviceWorkspaceUuid = 'different-workspace' as WorkspaceUuid
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: accountUuid,
workspace: workspaceUuid,
extra: { service: 'billing' }
})
mockDb.subscription.find.mockResolvedValue([])
await getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', { workspaceUuid: serviceWorkspaceUuid })
expect(mockDb.subscription.find).toHaveBeenCalledWith({ workspaceUuid: serviceWorkspaceUuid, status: 'active' })
expect(mockDb.getWorkspaceRole).not.toHaveBeenCalled()
})
test('should reject non-service users without workspace in token', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: accountUuid,
workspace: undefined,
extra: {}
})
await expect(getSubscriptions(mockCtx, mockDb, mockBranding, 'test-token', {})).rejects.toThrow(PlatformError)
})
})
@@ -14,6 +14,7 @@
//
import {
type AccountUuid,
type IntegrationKind,
type MeasureContext,
type PersonId,
@@ -29,11 +30,12 @@ import {
type Integration,
type IntegrationKey,
type IntegrationSecret,
type IntegrationSecretKey
type IntegrationSecretKey,
SubscriptionStatus,
SubscriptionType
} from '../types'
import * as utils from '../utils'
import {
addIntegrationSecret,
addSocialIdToPerson,
createIntegration,
deleteIntegration,
@@ -43,7 +45,9 @@ import {
listIntegrations,
listIntegrationsSecrets,
updateIntegration,
updateIntegrationSecret
updateIntegrationSecret,
addIntegrationSecret,
upsertSubscription
} from '../serviceOperations'
// Mock platform
@@ -1403,3 +1407,199 @@ describe('integration methods', () => {
})
})
})
describe('upsertSubscription', () => {
const mockCtx = {
error: jest.fn(),
info: jest.fn()
} as unknown as MeasureContext
const mockBranding = null
const mockToken = 'test-token'
let mockDb: any
let getWorkspaceByIdSpy: jest.SpyInstance
beforeEach(() => {
jest.clearAllMocks()
mockDb = {
subscription: {
findOne: jest.fn(),
insertOne: jest.fn(),
update: jest.fn()
}
}
// Mock getWorkspaceById utility function
getWorkspaceByIdSpy = jest.spyOn(utils, 'getWorkspaceById')
})
afterAll(() => {
getWorkspaceByIdSpy.mockRestore()
})
test('should create new subscription', async () => {
const workspaceUuid = 'test-workspace' as WorkspaceUuid
const accountUuid = 'test-account' as AccountUuid
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
extra: { service: 'payment' }
})
getWorkspaceByIdSpy.mockResolvedValue({ uuid: workspaceUuid })
mockDb.subscription.findOne.mockResolvedValue(null)
const subscriptionData = {
id: 'sub-123',
workspaceUuid,
accountUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123',
providerCheckoutId: 'checkout-456',
type: SubscriptionType.Tier,
status: SubscriptionStatus.Active,
plan: 'pro'
}
await upsertSubscription(mockCtx, mockDb, mockBranding, mockToken, subscriptionData)
expect(getWorkspaceByIdSpy).toHaveBeenCalledWith(mockDb, workspaceUuid)
expect(mockDb.subscription.findOne).toHaveBeenCalledWith({
provider: 'polar',
providerSubscriptionId: 'polar-sub-123'
})
expect(mockDb.subscription.insertOne).toHaveBeenCalledWith(
expect.objectContaining({
id: 'sub-123',
workspaceUuid,
accountUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123',
status: 'active',
plan: 'pro'
})
)
})
test('should update existing subscription', async () => {
const workspaceUuid = 'test-workspace' as WorkspaceUuid
const accountUuid = 'test-account' as AccountUuid
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
extra: { service: 'payment' }
})
const existingSubscription = {
id: 'existing-sub-id',
workspaceUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123'
}
getWorkspaceByIdSpy.mockResolvedValue({ uuid: workspaceUuid })
mockDb.subscription.findOne.mockResolvedValue(existingSubscription)
const subscriptionData = {
id: 'sub-123',
workspaceUuid,
accountUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123',
type: SubscriptionType.Tier,
status: SubscriptionStatus.Canceled,
plan: 'pro',
canceledAt: Date.now()
}
await upsertSubscription(mockCtx, mockDb, mockBranding, mockToken, subscriptionData)
expect(mockDb.subscription.update).toHaveBeenCalledWith(
{ id: 'existing-sub-id' },
expect.objectContaining({
status: 'canceled',
canceledAt: subscriptionData.canceledAt
})
)
expect(mockDb.subscription.insertOne).not.toHaveBeenCalled()
})
test('should reject non-billing service', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
extra: { service: 'other-service' }
})
const subscriptionData = {
workspaceUuid: 'test-workspace' as WorkspaceUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123'
} as any
await expect(upsertSubscription(mockCtx, mockDb, mockBranding, mockToken, subscriptionData)).rejects.toThrow(
new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
)
expect(mockDb.subscription.findOne).not.toHaveBeenCalled()
})
test('should reject if workspace not found', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
extra: { service: 'billing' }
})
getWorkspaceByIdSpy.mockResolvedValue(null)
const subscriptionData = {
workspaceUuid: 'nonexistent-workspace' as WorkspaceUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123'
} as any
await expect(upsertSubscription(mockCtx, mockDb, mockBranding, mockToken, subscriptionData)).rejects.toThrow(
PlatformError
)
expect(mockDb.subscription.findOne).not.toHaveBeenCalled()
})
test('should handle subscription with optional fields', async () => {
const workspaceUuid = 'test-workspace' as WorkspaceUuid
const accountUuid = 'test-account' as AccountUuid
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
extra: { service: 'payment' }
})
getWorkspaceByIdSpy.mockResolvedValue({ uuid: workspaceUuid })
mockDb.subscription.findOne.mockResolvedValue(null)
const subscriptionData = {
id: 'sub-123',
workspaceUuid,
accountUuid,
provider: 'polar',
providerSubscriptionId: 'polar-sub-123',
providerCheckoutId: 'checkout-456',
type: SubscriptionType.Tier,
status: SubscriptionStatus.Trialing,
plan: 'storage-100gb',
periodStart: Date.now(),
periodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000,
trialEnd: Date.now() + 7 * 24 * 60 * 60 * 1000,
providerData: {
customerExternalId: 'cus_123',
metadata: { source: 'website' }
}
}
await upsertSubscription(mockCtx, mockDb, mockBranding, mockToken, subscriptionData)
expect(mockDb.subscription.insertOne).toHaveBeenCalledWith(
expect.objectContaining({
providerCheckoutId: 'checkout-456',
trialEnd: subscriptionData.trialEnd,
providerData: subscriptionData.providerData
})
)
})
})
+3
View File
@@ -51,6 +51,7 @@ import type {
SocialId,
Sort,
UserProfile,
Subscription,
WorkspaceData,
WorkspaceInfoWithStatus,
WorkspaceInvite,
@@ -405,6 +406,7 @@ export class MongoAccountDB implements AccountDB {
integration: MongoDbCollection<Integration>
integrationSecret: MongoDbCollection<IntegrationSecret>
userProfile: MongoDbCollection<UserProfile, 'personUuid'>
subscription: MongoDbCollection<Subscription, 'id'>
workspaceMembers: MongoDbCollection<WorkspaceMember>
@@ -423,6 +425,7 @@ export class MongoAccountDB implements AccountDB {
this.integration = new MongoDbCollection<Integration>('integration', db)
this.integrationSecret = new MongoDbCollection<IntegrationSecret>('integrationSecret', db)
this.userProfile = new MongoDbCollection<UserProfile, 'personUuid'>('user_profile', db, 'personUuid')
this.subscription = new MongoDbCollection<Subscription, 'id'>('subscription', db, 'id')
this.workspaceMembers = new MongoDbCollection<WorkspaceMember>('workspaceMembers', db)
}
@@ -36,7 +36,8 @@ export function getMigrations (ns: string): [string, string][] {
getV15Migration(ns),
getV16Migration(ns),
getV17Migration(ns),
getV18Migration(ns)
getV18Migration(ns),
getV19Migration(ns)
]
}
@@ -490,3 +491,69 @@ function getV18Migration (ns: string): [string, string] {
`
]
}
function getV19Migration (ns: string): [string, string] {
return [
'account_db_v19_subscription_table',
`
/* ======= S U B S C R I P T I O N ======= */
/* Provider-agnostic subscription information for workspaces */
/* Managed by billing service via payment provider webhooks (e.g. Polar.sh, Stripe) */
/* Multiple active subscriptions allowed per workspace (tier + addons + support) */
/* Historical subscriptions preserved with status: canceled/expired */
CREATE TYPE IF NOT EXISTS ${ns}.subscription_status AS ENUM (
'active',
'trialing',
'past_due',
'canceled',
'paused',
'expired'
);
CREATE TABLE IF NOT EXISTS ${ns}.subscription (
id STRING NOT NULL DEFAULT gen_random_uuid()::STRING,
workspace_uuid UUID NOT NULL,
account_uuid UUID NOT NULL, -- Account that paid for the subscription
-- Provider details
provider STRING NOT NULL, -- Payment provider identifier (e.g. 'polar', 'stripe', 'manual')
provider_subscription_id STRING NOT NULL, -- External subscription ID from the provider
provider_checkout_id STRING, -- External checkout/session ID that created this subscription
-- Subscription classification
type STRING NOT NULL DEFAULT 'tier', -- tier, support, etc.
status ${ns}.subscription_status NOT NULL DEFAULT 'active',
plan STRING NOT NULL, -- Plan identifier (e.g. 'rare', 'epic', 'legendary', 'custom')
-- Amount paid (in cents, e.g. 9999 = $99.99)
-- Used primarily for pay-what-you-want/donation subscriptions to track actual payment
amount INT8,
-- Billing period (optional)
period_start BIGINT,
period_end BIGINT,
-- Trial information (optional)
trial_end BIGINT,
-- Cancellation info (optional)
canceled_at BIGINT,
will_cancel_at BIGINT, -- Scheduled cancellation date (cancel at period end)
-- Provider-specific data (stored as JSONB for flexibility)
-- e.g. customerExternalId, metadata, etc.
provider_data JSONB,
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
updated_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
CONSTRAINT subscription_pk PRIMARY KEY (id),
CONSTRAINT subscription_provider_subscription_id_unique UNIQUE (provider, provider_subscription_id),
CONSTRAINT subscription_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid),
CONSTRAINT subscription_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.account(uuid),
INDEX subscription_workspace_status_idx (workspace_uuid, status)
);
`
]
}
@@ -47,7 +47,8 @@ import type {
Integration,
IntegrationSecret,
AccountAggregatedInfo,
UserProfile
UserProfile,
Subscription
} from '../../types'
function toSnakeCase (str: string): string {
@@ -371,7 +372,7 @@ implements DbCollection<T> {
const castType = this.fieldTypes[key]
currIdx++
updateChunks.push(`"${snakeKey}" = ${formatVar(currIdx, castType)}`)
values.push(ops[key])
values.push(convertKeysToSnakeCase(ops[key]))
}
}
}
@@ -520,6 +521,7 @@ export class PostgresAccountDB implements AccountDB {
integration: PostgresDbCollection<Integration>
integrationSecret: PostgresDbCollection<IntegrationSecret>
userProfile: PostgresDbCollection<UserProfile, 'personUuid'>
subscription: PostgresDbCollection<Subscription, 'id'>
constructor (
readonly client: Sql,
@@ -569,6 +571,12 @@ export class PostgresAccountDB implements AccountDB {
idKey: 'personUuid',
withRetryClient
})
this.subscription = new PostgresDbCollection<Subscription, 'id'>('subscription', client, {
ns,
idKey: 'id',
timestampFields: ['periodStart', 'periodEnd', 'trialEnd', 'canceledAt', 'willCancelAt', 'createdOn', 'updatedOn'],
withRetryClient
})
}
getWsMembersTableName (): string {
+127 -1
View File
@@ -61,7 +61,10 @@ import {
type LoginInfoRequest,
type LoginInfoRequestData,
type Account,
type PersonWithProfile
type PersonWithProfile,
type Subscription,
SubscriptionStatus,
type Query
} from './types'
import {
addSocialIdBase,
@@ -2595,6 +2598,125 @@ async function getUserProfile (
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
/**
* Get subscriptions for a workspace
* - Regular users: Must be OWNER or MAINTAINER of the workspace (from token)
* - Services: Can query any workspace by workspaceUuid parameter
* By default returns only active subscriptions. Set activeOnly=false to include historical subscriptions.
* @public
*/
export async function getSubscriptions (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
workspaceUuid?: WorkspaceUuid // Optional: used by services only, undefined means all workspaces
activeOnly?: boolean // Optional: default true - only return active subscriptions
}
): Promise<Subscription[]> {
const { account, extra, workspace: tokenWorkspace } = decodeTokenVerbose(ctx, token)
const { workspaceUuid, activeOnly = true } = params
let targetWorkspace: WorkspaceUuid | null
// Check if this is a service token
const isService = extra?.service !== undefined
if (isService) {
// Services can query any workspace/all workspaces
targetWorkspace = workspaceUuid ?? null
} else {
// Regular users: use workspace from token (ignores workspaceUuid param)
if (tokenWorkspace === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
targetWorkspace = tokenWorkspace
// Verify user has OWNER or MAINTAINER role
const role = await db.getWorkspaceRole(account, targetWorkspace)
if (role === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const rolePower = getRolePower(role)
const maintainerPower = getRolePower(AccountRole.Maintainer)
if (rolePower < maintainerPower) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
// Fetch subscriptions for workspace (tier + addons + support)
// By default return only active subscriptions, unless activeOnly=false
const query: Query<Subscription> = targetWorkspace != null ? { workspaceUuid: targetWorkspace } : {}
if (activeOnly) {
query.status = SubscriptionStatus.Active
}
const subscriptions = await db.subscription.find(query)
return subscriptions
}
/**
* Get a subscription by its internal ID
* - Services: Can query any subscription
* - Regular users: Can only query subscriptions from their workspace (from token)
* @public
*/
export async function getSubscriptionById (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
subscriptionId: string // Internal subscription ID (UUID)
}
): Promise<Subscription | null> {
const { account, extra, workspace: tokenWorkspace } = decodeTokenVerbose(ctx, token)
const { subscriptionId } = params
// Check if this is a service token
const isService = extra?.service !== undefined
// Fetch the subscription first
const subscription = await db.subscription.findOne({ id: subscriptionId })
if (subscription === null || subscription === undefined) {
return null
}
if (isService) {
// Services can query any subscription by internal ID
return subscription
}
// Regular users: can only query subscriptions from their workspace (from token)
if (tokenWorkspace === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
// Verify the subscription belongs to the user's workspace
if (subscription.workspaceUuid !== tokenWorkspace) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
// Verify user has OWNER or MAINTAINER role in the workspace
const role = await db.getWorkspaceRole(account, tokenWorkspace)
if (role === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const rolePower = getRolePower(role)
const maintainerPower = getRolePower(AccountRole.Maintainer)
if (rolePower < maintainerPower) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return subscription
}
export type AccountMethods =
| AccountServiceMethods
| 'login'
@@ -2655,6 +2777,8 @@ export type AccountMethods =
| 'mergeSpecifiedPersons'
| 'setMyProfile'
| 'getUserProfile'
| 'getSubscriptions'
| 'getSubscriptionById'
/**
* @public
@@ -2704,6 +2828,8 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
mergeSpecifiedPersons: wrap(mergeSpecifiedPersons),
setMyProfile: wrap(setMyProfile),
getUserProfile: wrap(getUserProfile),
getSubscriptions: wrap(getSubscriptions),
getSubscriptionById: wrap(getSubscriptionById),
/* READ OPERATIONS */
getRegionInfo: wrap(getRegionInfo),
+110 -1
View File
@@ -43,6 +43,8 @@ import type {
IntegrationSecretKey,
Query,
SocialId,
Subscription,
SubscriptionData,
Workspace,
WorkspaceEvent,
WorkspaceInfoWithStatus,
@@ -985,6 +987,109 @@ export async function findPersonBySocialKey (
return socialId.personUuid
}
/**
* Upsert (create or update) subscription for a workspace
* Only accessible by payment service
* Creates new subscription or updates existing one based on providerId
* @public
*/
export async function upsertSubscription (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: SubscriptionData
): Promise<void> {
const { extra } = decodeTokenVerbose(ctx, token)
// Only payment service can upsert subscriptions
if (extra?.service !== 'payment') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const { workspaceUuid, provider, providerSubscriptionId } = params
// Verify workspace exists
const workspace = await getWorkspaceById(db, workspaceUuid)
if (workspace === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.WorkspaceNotFound, { workspaceUuid }))
}
// Check if subscription exists by provider + providerSubscriptionId (unique external ID)
const existing = await db.subscription.findOne({ provider, providerSubscriptionId })
const updateData = {
workspaceUuid: params.workspaceUuid,
accountUuid: params.accountUuid,
provider: params.provider,
providerSubscriptionId: params.providerSubscriptionId,
providerCheckoutId: params.providerCheckoutId,
amount: params.amount,
type: params.type,
status: params.status,
plan: params.plan,
periodStart: params.periodStart,
periodEnd: params.periodEnd,
trialEnd: params.trialEnd,
canceledAt: params.canceledAt,
willCancelAt: params.willCancelAt,
providerData: params.providerData,
updatedOn: Date.now()
}
if (existing !== null) {
// Update existing subscription
await db.subscription.update({ id: existing.id }, updateData)
ctx.info('Subscription updated', {
id: existing.id,
workspaceUuid,
status: params.status,
type: params.type,
plan: params.plan
})
} else {
// Create new subscription
await db.subscription.insertOne({
...updateData,
id: params.id,
createdOn: Date.now()
})
ctx.info('Subscription created', {
id: params.id,
workspaceUuid,
status: params.status,
type: params.type,
plan: params.plan
})
}
}
export async function getSubscriptionByProviderId (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
provider: string
providerSubscriptionId: string
}
): Promise<Subscription | null> {
const { extra } = decodeTokenVerbose(ctx, token)
// Only payment service can query subscriptions by provider ID
if (extra?.service !== 'payment') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
const { provider, providerSubscriptionId } = params
// Find subscription by provider and providerSubscriptionId (unique external ID)
const subscription = await db.subscription.findOne({
provider,
providerSubscriptionId
})
return subscription ?? null
}
export type AccountServiceMethods =
| 'getPendingWorkspace'
| 'updateWorkspaceInfo'
@@ -1012,6 +1117,8 @@ export type AccountServiceMethods =
| 'findPersonBySocialKey'
| 'listAccounts'
| 'findFullSocialIds'
| 'getSubscriptionByProviderId'
| 'upsertSubscription'
/**
* @public
@@ -1043,6 +1150,8 @@ export function getServiceMethods (): Partial<Record<AccountServiceMethods, Acco
findFullSocialIds: wrap(findFullSocialIds),
mergeSpecifiedAccounts: wrap(mergeSpecifiedAccounts),
findPersonBySocialKey: wrap(findPersonBySocialKey),
listAccounts: wrap(listAccounts)
listAccounts: wrap(listAccounts),
getSubscriptionByProviderId: wrap(getSubscriptionByProviderId),
upsertSubscription: wrap(upsertSubscription)
}
}
+71
View File
@@ -207,6 +207,76 @@ export interface UserProfile {
export type PersonWithProfile = Person & Omit<UserProfile, 'personUuid'>
/**
* Workspace subscription status
* Provider-agnostic abstraction for billing state
*/
export enum SubscriptionStatus {
Active = 'active', // Subscription is active and in good standing
Trialing = 'trialing', // In trial period
PastDue = 'past_due', // Payment failed but still providing service
Canceled = 'canceled', // Subscription has been canceled
Paused = 'paused', // Subscription is paused
Expired = 'expired' // Subscription or trial has expired
}
/**
* Subscription type/purpose
* Allows multiple active subscriptions per workspace for different purposes
*/
export enum SubscriptionType {
Tier = 'tier', // Main workspace tier (free, starter, pro, enterprise)
Support = 'support' // Voluntary support/donation subscription
}
/**
* Workspace subscription information
* Provider-agnostic subscription data managed by billing service
* Multiple subscriptions can be active per workspace (tier + addons + support)
* Historical subscriptions are preserved with status: canceled/expired
*/
export interface Subscription {
id: string // Our internal unique subscription ID (UUID)
workspaceUuid: WorkspaceUuid
accountUuid: AccountUuid // Account that paid for the subscription
// Provider details
provider: string // Payment provider identifier (e.g. 'polar', 'stripe', 'manual')
providerSubscriptionId: string // External subscription ID from the provider
providerCheckoutId?: string // External checkout/session ID that created this subscription
// Subscription classification
type: SubscriptionType // What this subscription is for (tier, addon, support)
status: SubscriptionStatus // Current status
plan: string // Plan/product identifier (e.g. 'free', 'pro', 'storage-100gb', 'supporter')
// Amount paid (in cents, e.g. 9999 = $99.99)
// Used primarily for pay-what-you-want/donation subscriptions to track actual payment
amount?: number
// Billing period (optional - not set for free/manual plans)
periodStart?: Timestamp
periodEnd?: Timestamp
// Trial information (optional)
trialEnd?: Timestamp
// Cancellation info (optional)
canceledAt?: Timestamp
willCancelAt?: Timestamp // Scheduled cancellation date (cancel at period end)
// Provider-specific data (stored as JSONB for flexibility)
// This allows billing service to store additional provider fields if needed
// e.g. customerExternalId, metadata, etc. Some providers (like Polar.sh) allow using
// our own customer ID and don't require tracking their external customer ID
providerData?: Record<string, any>
createdOn: Timestamp
updatedOn: Timestamp
}
export type SubscriptionData = Omit<Subscription, 'createdOn' | 'updatedOn'>
/* ========= S U P P L E M E N T A R Y ========= */
export interface WorkspaceInfoWithStatus extends Workspace {
@@ -237,6 +307,7 @@ export interface AccountDB {
integration: DbCollection<Integration>
integrationSecret: DbCollection<IntegrationSecret>
userProfile: DbCollection<UserProfile>
subscription: DbCollection<Subscription>
init: () => Promise<void>
createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise<WorkspaceUuid>
+1 -1
View File
@@ -52,7 +52,7 @@
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/core": "^0.7.18",
"@hcengineering/account": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/server-client": "^0.7.15",
"@hcengineering/server-storage": "^0.7.15",
+1 -1
View File
@@ -53,7 +53,7 @@
"express-static-gzip": "^2.2.0",
"uuid": "^8.3.2",
"cors": "^2.8.5",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/server-core": "^0.7.16",
"@hcengineering/storage": "^0.7.17",
"@hcengineering/server-storage": "^0.7.15",
+2
View File
@@ -276,6 +276,7 @@ export function start (
streamUrl?: string
mailUrl?: string
billingUrl?: string
paymentUrl?: string
pulseUrl?: string
hulylakeUrl?: string
datalakeUrl?: string
@@ -354,6 +355,7 @@ export function start (
HIDE_LOCAL_LOGIN: config.hideLocalLogin,
MAIL_URL: config.mailUrl,
BILLING_URL: config.billingUrl,
PAYMENT_URL: config.paymentUrl,
PULSE_URL: config.pulseUrl,
HULYLAKE_URL: config.hulylakeUrl,
DATALAKE_URL: config.datalakeUrl,
+3
View File
@@ -119,6 +119,8 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
const billingUrl = process.env.BILLING_URL
const paymentUrl = process.env.PAYMENT_URL
const hulylakeUrl = process.env.HULYLAKE_URL
const datalakeUrl = process.env.DATALAKE_URL
@@ -152,6 +154,7 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record<string, st
streamUrl,
mailUrl,
billingUrl,
paymentUrl,
pulseUrl,
hulylakeUrl,
datalakeUrl
+1 -1
View File
@@ -43,7 +43,7 @@
"@hcengineering/contact": "^0.7.0",
"@hcengineering/client-resources": "^0.7.17",
"@hcengineering/client": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/importer": "^0.7.0",
"@hcengineering/model": "^0.7.17",
"@hcengineering/rank": "^0.7.17",
+1 -1
View File
@@ -56,7 +56,7 @@
"@hcengineering/server-token": "^0.7.17",
"@hcengineering/server-notification": "^0.7.0",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/server-backup": "^0.7.0",
"@hcengineering/postgres": "^0.7.15",
"@hcengineering/mongo": "^0.7.15"
+1 -1
View File
@@ -55,7 +55,7 @@
},
"dependencies": {
"@hcengineering/account": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/ai-bot": "^0.7.0",
"@hcengineering/analytics-service": "^0.7.17",
"@hcengineering/attachment": "^0.7.0",
+1 -1
View File
@@ -64,7 +64,7 @@
"@hcengineering/server-backup": "^0.7.0",
"@hcengineering/core": "^0.7.18",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"cors": "^2.8.5",
"dotenv": "~16.0.0",
"express": "^4.21.2",
+1 -1
View File
@@ -65,7 +65,7 @@
"@hcengineering/server-client": "^0.7.15",
"@hcengineering/core": "^0.7.18",
"@hcengineering/datalake": "^0.7.15",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/platform": "^0.7.17",
"cors": "^2.8.5",
"dotenv": "~16.0.0",
@@ -51,7 +51,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/analytics": "^0.7.17",
"@hcengineering/analytics-service": "^0.7.17",
"@hcengineering/api-client": "^0.7.18",
+1 -1
View File
@@ -56,7 +56,7 @@
"@hcengineering/attachment": "^0.7.0",
"@hcengineering/calendar": "^0.7.0",
"@hcengineering/client": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/client-resources": "^0.7.17",
"@hcengineering/contact": "^0.7.0",
"@hcengineering/core": "^0.7.18",
+1 -1
View File
@@ -66,7 +66,7 @@
"@hcengineering/core": "^0.7.18",
"@hcengineering/kafka": "^0.7.15",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"cors": "^2.8.5",
"dotenv": "~16.0.0",
"express": "^4.21.2",
+1 -1
View File
@@ -65,7 +65,7 @@
"@hcengineering/platform": "^0.7.17",
"@hcengineering/server-client": "^0.7.15",
"@hcengineering/account": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/notification": "^0.7.0",
"@hcengineering/model-attachment": "^0.7.0",
"@hcengineering/model-core": "^0.7.0",
@@ -67,6 +67,6 @@
"@hcengineering/diffview": "^0.7.0",
"@hcengineering/activity": "^0.7.0",
"@hcengineering/activity-resources": "^0.7.0",
"@hcengineering/account-client": "^0.7.17"
"@hcengineering/account-client": "^0.7.18"
}
}
+1 -1
View File
@@ -62,7 +62,7 @@
"dependencies": {
"@hcengineering/core": "^0.7.18",
"@hcengineering/account": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/platform": "^0.7.17",
"@hcengineering/server-client": "^0.7.15",
"@hcengineering/server-token": "^0.7.17",
+1 -1
View File
@@ -58,7 +58,7 @@
},
"dependencies": {
"@hcengineering/attachment": "^0.7.0",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/api-client": "^0.7.18",
"@hcengineering/card": "^0.7.0",
"@hcengineering/chat": "^0.7.0",
+1 -1
View File
@@ -67,7 +67,7 @@
"@hcengineering/server-token": "^0.7.17",
"@hcengineering/datalake": "^0.7.15",
"@hcengineering/s3": "^0.7.15",
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/billing-client": "^0.7.0",
"livekit-server-sdk": "^2.13.3",
"jwt-simple": "^0.5.6",
+1 -1
View File
@@ -46,7 +46,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/api-client": "^0.7.18",
"@hcengineering/card": "^0.7.0",
"@hcengineering/chat": "^0.7.0",
+1 -1
View File
@@ -57,7 +57,7 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@hcengineering/account-client": "^0.7.17",
"@hcengineering/account-client": "^0.7.18",
"@hcengineering/analytics-service": "^0.7.17",
"@hcengineering/api-client": "^0.7.18",
"@hcengineering/card": "^0.7.0",
@@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}
+4
View File
@@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/

Some files were not shown because too many files have changed in this diff Show More