mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Subscriptions (#10114)
This commit is contained in:
Vendored
+21
@@ -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",
|
||||
|
||||
Generated
+602
-424
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ?? '')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!/lib/**
|
||||
!CHANGELOG.md
|
||||
/lib/**/__tests__/
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
|
||||
"rigPackageName": "@hcengineering/platform-rig"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
roots: ["./src"],
|
||||
coverageReporters: ["text-summary", "html"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './client'
|
||||
export * from './types'
|
||||
export * from './error'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "下記からプランを選択して開始してください"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Подтвердить"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "选择下面的计划开始使用"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -14,5 +14,7 @@
|
||||
//
|
||||
import { billingId, billingPlugin } from './plugin'
|
||||
|
||||
export * from './types'
|
||||
|
||||
export { billingId }
|
||||
export default billingPlugin
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user