feat(billing): start demos on the core plan and apply tariff levels
New signups get the core module set with the 7-day demo. Superadmin can apply core, team, organization, or dedicated and the owner's environments take that level's access. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,9 @@ import {
|
|||||||
defaultEnabledModules,
|
defaultEnabledModules,
|
||||||
isPermanentBillingPlan,
|
isPermanentBillingPlan,
|
||||||
isOrgModuleKey,
|
isOrgModuleKey,
|
||||||
|
modulesForProductPlan,
|
||||||
|
PRODUCT_PLAN_LIMITS,
|
||||||
|
type ProductPlanCode,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
import { PlatformAdminAlertService } from './platform-admin-alert.service';
|
||||||
@@ -414,7 +417,37 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultModulesForNewOrg(): OrgModuleKey[] {
|
defaultModulesForNewOrg(): OrgModuleKey[] {
|
||||||
return defaultEnabledModules();
|
return modulesForProductPlan('core');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** سطح امکانات را روی همهٔ محیطهای مالک میگذارد و سقف هسته/تیم/سازمان را هم تنظیم میکند. */
|
||||||
|
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
|
||||||
|
const modules = modulesForProductPlan(code);
|
||||||
|
const owner = await this.prisma.membership.findFirst({
|
||||||
|
where: { organizationId, role: 'owner', isActive: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
|
||||||
|
for (const id of orgIds) {
|
||||||
|
await this.setOrgModules(id, modules);
|
||||||
|
}
|
||||||
|
const limits = PRODUCT_PLAN_LIMITS[code];
|
||||||
|
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
|
||||||
|
const account = await this.getByOwnerUserId(owner.userId);
|
||||||
|
if (account) {
|
||||||
|
await this.prisma.billingAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: {
|
||||||
|
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
|
||||||
|
...(limits.maxOrganizations != null
|
||||||
|
? { maxOrganizations: limits.maxOrganizations }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
async countExpiringSoon() {
|
async countExpiringSoon() {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { USER_ROLES, type UserRole } from '@mento/shared';
|
import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared';
|
||||||
|
|
||||||
export class PlatformLoginDto {
|
export class PlatformLoginDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
|
|||||||
quotaGb!: number | null;
|
quotaGb!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApplyProductPlanDto {
|
||||||
|
@IsIn(PRODUCT_PLAN_CODES)
|
||||||
|
planCode!: ProductPlanCode;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateOrgModulesDto {
|
export class UpdateOrgModulesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import {
|
import {
|
||||||
ApplyBillingPlanDto,
|
ApplyBillingPlanDto,
|
||||||
|
ApplyProductPlanDto,
|
||||||
SetStorageQuotaDto,
|
SetStorageQuotaDto,
|
||||||
SetUserCapDto,
|
SetUserCapDto,
|
||||||
CreatePlatformAdminDto,
|
CreatePlatformAdminDto,
|
||||||
@@ -168,6 +169,11 @@ export class PlatformController {
|
|||||||
return this.platform.setUserCap(id, dto.maxUsers);
|
return this.platform.setUserCap(id, dto.maxUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('organizations/:id/product-plan')
|
||||||
|
applyProductPlan(@Param('id') id: string, @Body() dto: ApplyProductPlanDto) {
|
||||||
|
return this.platform.applyProductPlan(id, dto.planCode);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('organizations/:id/modules')
|
@Patch('organizations/:id/modules')
|
||||||
updateOrganizationModules(
|
updateOrganizationModules(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
PlatformOrganizationDto,
|
PlatformOrganizationDto,
|
||||||
PlatformStatsDto,
|
PlatformStatsDto,
|
||||||
PlatformUserDto,
|
PlatformUserDto,
|
||||||
|
ProductPlanCode,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { isBusinessField } from '@mento/shared';
|
import { isBusinessField } from '@mento/shared';
|
||||||
@@ -472,6 +473,16 @@ export class PlatformService {
|
|||||||
return this.getUser(userId);
|
return this.getUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async applyProductPlan(id: string, planCode: ProductPlanCode) {
|
||||||
|
const existing = await this.prisma.organization.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('محیط پیدا نشد');
|
||||||
|
await this.billing.applyProductPlan(id, planCode);
|
||||||
|
return this.getOrganization(id);
|
||||||
|
}
|
||||||
|
|
||||||
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
|
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
|
||||||
const existing = await this.prisma.organization.findUnique({
|
const existing = await this.prisma.organization.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, StorageUsageDto } from '@mento/shared';
|
import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, ProductPlanCode, StorageUsageDto } from '@mento/shared';
|
||||||
import {
|
import {
|
||||||
BILLING_PLAN_CODES,
|
BILLING_PLAN_CODES,
|
||||||
BILLING_PLAN_LABELS,
|
BILLING_PLAN_LABELS,
|
||||||
BILLING_USER_CAPS,
|
BILLING_USER_CAPS,
|
||||||
|
PRODUCT_PLAN_CODES,
|
||||||
|
PRODUCT_PLAN_LABELS,
|
||||||
isPermanentBillingPlan,
|
isPermanentBillingPlan,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
@@ -19,6 +21,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
apply: [planCode: BillingPlanCode];
|
apply: [planCode: BillingPlanCode];
|
||||||
|
product: [planCode: ProductPlanCode];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const usage = ref<StorageUsageDto | null>(null);
|
const usage = ref<StorageUsageDto | null>(null);
|
||||||
@@ -178,6 +181,22 @@ onMounted(() => {
|
|||||||
{{ BILLING_PLAN_LABELS[code] }}
|
{{ BILLING_PLAN_LABELS[code] }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<strong>سطح امکانات</strong>
|
||||||
|
<p class="muted">
|
||||||
|
انتخاب سطح، دسترسی محیطهای این مالک را طبق تعرفه تیک میزند. هسته و تیم و سازمان سقف کاربر و محیط را هم تنظیم میکنند. ثبتنام دمو از هسته شروع میشود.
|
||||||
|
</p>
|
||||||
|
<div class="plan-row">
|
||||||
|
<button
|
||||||
|
v-for="code in PRODUCT_PLAN_CODES"
|
||||||
|
:key="code"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="busyId === `product-${userId}-${code}`"
|
||||||
|
@click="emit('product', code)"
|
||||||
|
>
|
||||||
|
{{ PRODUCT_PLAN_LABELS[code] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div v-if="billing" class="storage">
|
<div v-if="billing" class="storage">
|
||||||
<strong>تعداد کاربر</strong>
|
<strong>تعداد کاربر</strong>
|
||||||
<p v-if="seats" class="muted">
|
<p v-if="seats" class="muted">
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import type {
|
|||||||
BillingPlanCode,
|
BillingPlanCode,
|
||||||
PlatformOrganizationDto,
|
PlatformOrganizationDto,
|
||||||
PlatformOrgMemberDto,
|
PlatformOrgMemberDto,
|
||||||
|
ProductPlanCode,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { BILLING_PLAN_LABELS } from '@mento/shared';
|
import { BILLING_PLAN_LABELS, PRODUCT_PLAN_LABELS } from '@mento/shared';
|
||||||
import { platformApi } from '@/lib/platform-api';
|
import { platformApi } from '@/lib/platform-api';
|
||||||
import AdmBillingPanel from './AdmBillingPanel.vue';
|
import AdmBillingPanel from './AdmBillingPanel.vue';
|
||||||
|
|
||||||
@@ -58,6 +59,21 @@ async function loadOwners() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyProduct(userId: string, planCode: ProductPlanCode) {
|
||||||
|
busyId.value = `product-${userId}-${planCode}`;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
await platformApi.post(`/platform/organizations/${orgId.value}/product-plan`, { planCode });
|
||||||
|
await loadOwners();
|
||||||
|
success.value = `سطح «${PRODUCT_PLAN_LABELS[planCode]}» روی دسترسیها اعمال شد`;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'اعمال سطح امکانات ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
busyId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyPlan(userId: string, planCode: BillingPlanCode) {
|
async function applyPlan(userId: string, planCode: BillingPlanCode) {
|
||||||
busyId.value = `plan-${userId}-${planCode}`;
|
busyId.value = `plan-${userId}-${planCode}`;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -100,11 +116,12 @@ onMounted(() => {
|
|||||||
<p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p>
|
<p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p>
|
||||||
<AdmBillingPanel
|
<AdmBillingPanel
|
||||||
v-for="owner in owners"
|
v-for="owner in owners"
|
||||||
:key="`${owner.userId}-${owner.billing?.planCode ?? ''}-${owner.billing?.expiresAt ?? ''}`"
|
:key="`${owner.userId}-${owner.billing?.planCode ?? ''}-${owner.billing?.expiresAt ?? ''}-${owner.billing?.maxUsers ?? ''}-${owner.billing?.maxOrganizations ?? ''}`"
|
||||||
:user-id="owner.userId"
|
:user-id="owner.userId"
|
||||||
:billing="owner.billing"
|
:billing="owner.billing"
|
||||||
:busy-id="busyId"
|
:busy-id="busyId"
|
||||||
@apply="applyPlan(owner.userId, $event)"
|
@apply="applyPlan(owner.userId, $event)"
|
||||||
|
@product="applyProduct(owner.userId, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CreatePlatformOrganizationRequest,
|
CreatePlatformOrganizationRequest,
|
||||||
OrgModuleKey,
|
OrgModuleKey,
|
||||||
PlatformOrganizationDto,
|
PlatformOrganizationDto,
|
||||||
|
ProductPlanCode,
|
||||||
UpdatePlatformOrganizationRequest,
|
UpdatePlatformOrganizationRequest,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
BILLING_PLAN_LABELS,
|
BILLING_PLAN_LABELS,
|
||||||
ORG_MODULE_GROUPS,
|
ORG_MODULE_GROUPS,
|
||||||
ORG_MODULE_LABELS,
|
ORG_MODULE_LABELS,
|
||||||
|
PRODUCT_PLAN_CODES,
|
||||||
|
PRODUCT_PLAN_LABELS,
|
||||||
USER_ROLE_LABELS,
|
USER_ROLE_LABELS,
|
||||||
USER_ROLES,
|
USER_ROLES,
|
||||||
isBusinessField,
|
isBusinessField,
|
||||||
@@ -265,6 +268,25 @@ async function setMembershipRole(membershipId: string, role: UserRole) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyProductPlan(orgId: string, planCode: ProductPlanCode) {
|
||||||
|
savingModules.value = true;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await platformApi.post<PlatformOrganizationDto>(
|
||||||
|
`/platform/organizations/${orgId}/product-plan`,
|
||||||
|
{ planCode },
|
||||||
|
);
|
||||||
|
moduleDraft.value = [...(data.enabledModules ?? [])];
|
||||||
|
if (detail.value?.id === orgId) detail.value = data;
|
||||||
|
success.value = `سطح «${PRODUCT_PLAN_LABELS[planCode]}» روی دسترسیها اعمال شد`;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = apiError(e, 'اعمال سطح امکانات ناموفق بود');
|
||||||
|
} finally {
|
||||||
|
savingModules.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleModule(key: OrgModuleKey) {
|
function toggleModule(key: OrgModuleKey) {
|
||||||
if (moduleDraft.value.includes(key)) {
|
if (moduleDraft.value.includes(key)) {
|
||||||
moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
|
moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
|
||||||
@@ -434,6 +456,16 @@ onMounted(() => {
|
|||||||
<div class="modules">
|
<div class="modules">
|
||||||
<div class="modules-head">
|
<div class="modules-head">
|
||||||
<strong>ماژولهای فعال</strong>
|
<strong>ماژولهای فعال</strong>
|
||||||
|
<button
|
||||||
|
v-for="code in PRODUCT_PLAN_CODES"
|
||||||
|
:key="code"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="savingModules"
|
||||||
|
@click="applyProductPlan(org.id, code)"
|
||||||
|
>
|
||||||
|
{{ PRODUCT_PLAN_LABELS[code] }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -354,6 +354,90 @@ export interface SetStorageQuotaRequest {
|
|||||||
/** سقفهای آمادهٔ تعداد کاربر. بالاتر از ۲۰ توافقی و با عدد جدا ذخیره میشود. */
|
/** سقفهای آمادهٔ تعداد کاربر. بالاتر از ۲۰ توافقی و با عدد جدا ذخیره میشود. */
|
||||||
export const BILLING_USER_CAPS = [3, 10, 20] as const;
|
export const BILLING_USER_CAPS = [3, 10, 20] as const;
|
||||||
|
|
||||||
|
/** سطح امکانات اشتراک. دمو ۷ روزه از هسته شروع میشود. */
|
||||||
|
export type ProductPlanCode = "core" | "team" | "organization" | "dedicated";
|
||||||
|
|
||||||
|
export const PRODUCT_PLAN_CODES: ProductPlanCode[] = [
|
||||||
|
"core",
|
||||||
|
"team",
|
||||||
|
"organization",
|
||||||
|
"dedicated",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRODUCT_PLAN_LABELS: Record<ProductPlanCode, string> = {
|
||||||
|
core: "هسته",
|
||||||
|
team: "تیم",
|
||||||
|
organization: "سازمان",
|
||||||
|
dedicated: "اختصاصی",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** null یعنی سقف را عوض نکن؛ اختصاصی توافقی است. */
|
||||||
|
export const PRODUCT_PLAN_LIMITS: Record<
|
||||||
|
ProductPlanCode,
|
||||||
|
{ maxUsers: number | null; maxOrganizations: number | null }
|
||||||
|
> = {
|
||||||
|
core: { maxUsers: 3, maxOrganizations: 1 },
|
||||||
|
team: { maxUsers: 10, maxOrganizations: 3 },
|
||||||
|
organization: { maxUsers: 20, maxOrganizations: 5 },
|
||||||
|
dedicated: { maxUsers: null, maxOrganizations: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRODUCT_PLAN_CORE: OrgModuleKey[] = [
|
||||||
|
"customers",
|
||||||
|
"attachments",
|
||||||
|
"activities",
|
||||||
|
"notes",
|
||||||
|
"products",
|
||||||
|
"invoices",
|
||||||
|
"quotes",
|
||||||
|
"lead_forms",
|
||||||
|
"team_settings",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCT_PLAN_TEAM: OrgModuleKey[] = [
|
||||||
|
"routines",
|
||||||
|
"conversations",
|
||||||
|
"pipeline",
|
||||||
|
"saved_views",
|
||||||
|
"leads",
|
||||||
|
"commissions",
|
||||||
|
"sales_forecast",
|
||||||
|
"reports",
|
||||||
|
"project_boards",
|
||||||
|
"sms_campaigns",
|
||||||
|
"message_automations",
|
||||||
|
"tickets",
|
||||||
|
"faq",
|
||||||
|
"cheques",
|
||||||
|
"treasury",
|
||||||
|
"workflow_automations",
|
||||||
|
"teams",
|
||||||
|
"gamification",
|
||||||
|
"custom_fields",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCT_PLAN_ORGANIZATION: OrgModuleKey[] = [
|
||||||
|
"inventory",
|
||||||
|
"currencies",
|
||||||
|
"tahesab",
|
||||||
|
"workflow_automations_advanced",
|
||||||
|
"audit_logs",
|
||||||
|
"prior_data_archive",
|
||||||
|
"public_api",
|
||||||
|
"woocommerce",
|
||||||
|
"montify",
|
||||||
|
"voip",
|
||||||
|
"reservations",
|
||||||
|
"loyalty",
|
||||||
|
"behavior",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function modulesForProductPlan(code: ProductPlanCode): OrgModuleKey[] {
|
||||||
|
if (code === "core") return [...PRODUCT_PLAN_CORE];
|
||||||
|
if (code === "team") return [...PRODUCT_PLAN_CORE, ...PRODUCT_PLAN_TEAM];
|
||||||
|
return [...PRODUCT_PLAN_CORE, ...PRODUCT_PLAN_TEAM, ...PRODUCT_PLAN_ORGANIZATION];
|
||||||
|
}
|
||||||
|
|
||||||
export interface OwnerSeatUsageDto {
|
export interface OwnerSeatUsageDto {
|
||||||
usedUsers: number;
|
usedUsers: number;
|
||||||
maxUsers: number | null;
|
maxUsers: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user