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:
2026-11-10 14:08:14 +03:30
co-authored by Cursor
parent 5b26b9156d
commit 932dc3f1c1
8 changed files with 212 additions and 5 deletions
+34 -1
View File
@@ -21,6 +21,9 @@ import {
defaultEnabledModules,
isPermanentBillingPlan,
isOrgModuleKey,
modulesForProductPlan,
PRODUCT_PLAN_LIMITS,
type ProductPlanCode,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
@@ -414,7 +417,37 @@ export class BillingService {
}
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() {
+6 -1
View File
@@ -13,7 +13,7 @@ import {
MinLength,
ValidateIf,
} 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 {
@IsString()
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
quotaGb!: number | null;
}
export class ApplyProductPlanDto {
@IsIn(PRODUCT_PLAN_CODES)
planCode!: ProductPlanCode;
}
export class UpdateOrgModulesDto {
@IsArray()
@IsString({ each: true })
@@ -13,6 +13,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApplyBillingPlanDto,
ApplyProductPlanDto,
SetStorageQuotaDto,
SetUserCapDto,
CreatePlatformAdminDto,
@@ -168,6 +169,11 @@ export class PlatformController {
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')
updateOrganizationModules(
@Param('id') id: string,
+11
View File
@@ -9,6 +9,7 @@ import type {
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
ProductPlanCode,
UserRole,
} from '@mento/shared';
import { isBusinessField } from '@mento/shared';
@@ -472,6 +473,16 @@ export class PlatformService {
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) {
const existing = await this.prisma.organization.findUnique({
where: { id },
+20 -1
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, StorageUsageDto } from '@mento/shared';
import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, ProductPlanCode, StorageUsageDto } from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
BILLING_USER_CAPS,
PRODUCT_PLAN_CODES,
PRODUCT_PLAN_LABELS,
isPermanentBillingPlan,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
@@ -19,6 +21,7 @@ const props = defineProps<{
const emit = defineEmits<{
apply: [planCode: BillingPlanCode];
product: [planCode: ProductPlanCode];
}>();
const usage = ref<StorageUsageDto | null>(null);
@@ -178,6 +181,22 @@ onMounted(() => {
{{ BILLING_PLAN_LABELS[code] }}
</button>
</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">
<strong>تعداد کاربر</strong>
<p v-if="seats" class="muted">
@@ -6,8 +6,9 @@ import type {
BillingPlanCode,
PlatformOrganizationDto,
PlatformOrgMemberDto,
ProductPlanCode,
} 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 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) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
@@ -100,11 +116,12 @@ onMounted(() => {
<p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p>
<AdmBillingPanel
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"
:billing="owner.billing"
:busy-id="busyId"
@apply="applyPlan(owner.userId, $event)"
@product="applyProduct(owner.userId, $event)"
/>
</div>
</div>
@@ -5,6 +5,7 @@ import type {
CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto,
ProductPlanCode,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
@@ -12,6 +13,8 @@ import {
BILLING_PLAN_LABELS,
ORG_MODULE_GROUPS,
ORG_MODULE_LABELS,
PRODUCT_PLAN_CODES,
PRODUCT_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
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) {
if (moduleDraft.value.includes(key)) {
moduleDraft.value = moduleDraft.value.filter((k) => k !== key);
@@ -434,6 +456,16 @@ onMounted(() => {
<div class="modules">
<div class="modules-head">
<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
class="btn"
type="button"