diff --git a/be/apps/core/package.json b/be/apps/core/package.json index bae4c088..e28cb08b 100644 --- a/be/apps/core/package.json +++ b/be/apps/core/package.json @@ -25,6 +25,7 @@ "@afilmory/task-queue": "workspace:*", "@afilmory/utils": "workspace:*", "@aws-sdk/client-s3": "3.929.0", + "@creem_io/better-auth": "0.0.8", "@hono/node-server": "^1.19.6", "@resvg/resvg-js": "2.6.2", "better-auth": "1.3.34", diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts index 6c29c778..10e65bcb 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.constants.ts @@ -1,4 +1,12 @@ import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils' +import { + BILLING_PLAN_DEFINITIONS, + BILLING_PLAN_IDS, + BILLING_PLAN_OVERRIDES_SETTING_KEY, + BILLING_PLAN_PRICING_SETTING_KEY, + BILLING_PLAN_PRODUCTS_SETTING_KEY, +} from 'core/modules/platform/billing/billing-plan.constants' +import type { BillingPlanId, BillingPlanQuota } from 'core/modules/platform/billing/billing-plan.types' import { z } from 'zod' const nonEmptyString = z.string().trim().min(1) @@ -88,9 +96,74 @@ export const SYSTEM_SETTING_DEFINITIONS = { defaultValue: null as string | null, isSensitive: true, }, + billingPlanOverrides: { + key: BILLING_PLAN_OVERRIDES_SETTING_KEY, + schema: z.record(z.string(), z.any()), + defaultValue: {}, + isSensitive: false, + }, + billingPlanProducts: { + key: BILLING_PLAN_PRODUCTS_SETTING_KEY, + schema: z.record(z.string(), z.any()), + defaultValue: {}, + isSensitive: false, + }, + billingPlanPricing: { + key: BILLING_PLAN_PRICING_SETTING_KEY, + schema: z.record(z.string(), z.any()), + defaultValue: {}, + isSensitive: false, + }, } as const -export type SystemSettingField = keyof typeof SYSTEM_SETTING_DEFINITIONS -export type SystemSettingKey = (typeof SYSTEM_SETTING_DEFINITIONS)[SystemSettingField]['key'] +const BILLING_PLAN_QUOTA_KEYS = [ + 'monthlyAssetProcessLimit', + 'libraryItemLimit', + 'maxUploadSizeMb', + 'maxSyncObjectSizeMb', +] as const +export type BillingPlanQuotaFieldKey = (typeof BILLING_PLAN_QUOTA_KEYS)[number] + +const BILLING_PLAN_PRICING_KEYS = ['monthlyPrice', 'currency'] as const +export type BillingPlanPricingFieldKey = (typeof BILLING_PLAN_PRICING_KEYS)[number] + +const BILLING_PLAN_PAYMENT_KEYS = ['creemProductId'] as const +export type BillingPlanPaymentFieldKey = (typeof BILLING_PLAN_PAYMENT_KEYS)[number] + +export type BillingPlanQuotaField = `billingPlan.${BillingPlanId}.quota.${BillingPlanQuotaFieldKey}` +export type BillingPlanPricingField = `billingPlan.${BillingPlanId}.pricing.${BillingPlanPricingFieldKey}` +export type BillingPlanPaymentField = `billingPlan.${BillingPlanId}.payment.${BillingPlanPaymentFieldKey}` + +export type BillingPlanSettingField = BillingPlanQuotaField | BillingPlanPricingField | BillingPlanPaymentField + +export type SystemSettingDbField = keyof typeof SYSTEM_SETTING_DEFINITIONS + +export type SystemSettingField = SystemSettingDbField | BillingPlanSettingField +export type SystemSettingKey = (typeof SYSTEM_SETTING_DEFINITIONS)[SystemSettingDbField]['key'] + +export const BILLING_PLAN_FIELD_DESCRIPTORS = { + quotas: BILLING_PLAN_IDS.flatMap((planId) => + BILLING_PLAN_QUOTA_KEYS.map((key) => ({ + planId, + key, + field: `billingPlan.${planId}.quota.${key}` as BillingPlanQuotaField, + defaultValue: BILLING_PLAN_DEFINITIONS[planId].quotas[key as keyof BillingPlanQuota], + })), + ), + pricing: BILLING_PLAN_IDS.flatMap((planId) => + BILLING_PLAN_PRICING_KEYS.map((key) => ({ + planId, + key, + field: `billingPlan.${planId}.pricing.${key}` as BillingPlanPricingField, + })), + ), + payment: BILLING_PLAN_IDS.flatMap((planId) => + BILLING_PLAN_PAYMENT_KEYS.map((key) => ({ + planId, + key, + field: `billingPlan.${planId}.payment.${key}` as BillingPlanPaymentField, + })), + ), +} as const export const SYSTEM_SETTING_KEYS = Object.values(SYSTEM_SETTING_DEFINITIONS).map((definition) => definition.key) diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts index b7c5ac8b..66bf1d9f 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.service.ts @@ -2,20 +2,37 @@ import { authUsers } from '@afilmory/db' import { DbAccessor } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' import type { SocialProvidersConfig } from 'core/modules/platform/auth/auth.config' -import { BILLING_PLAN_OVERRIDES_SETTING_KEY } from 'core/modules/platform/billing/billing-plan.constants' -import type { BillingPlanOverrides } from 'core/modules/platform/billing/billing-plan.types' +import { + BILLING_PLAN_OVERRIDES_SETTING_KEY, + BILLING_PLAN_PRICING_SETTING_KEY, + BILLING_PLAN_PRODUCTS_SETTING_KEY, +} from 'core/modules/platform/billing/billing-plan.constants' +import type { + BillingPlanId, + BillingPlanOverrides, + BillingPlanPaymentInfo, + BillingPlanPricing, + BillingPlanPricingConfigs, + BillingPlanProductConfigs, + BillingPlanQuota, +} from 'core/modules/platform/billing/billing-plan.types' import { sql } from 'drizzle-orm' import { injectable } from 'tsyringe' -import type {ZodType} from 'zod'; -import { z } from 'zod' +import type { ZodType } from 'zod' +import { z } from 'zod' -import { SYSTEM_SETTING_DEFINITIONS, SYSTEM_SETTING_KEYS } from './system-setting.constants' +import type { SystemSettingDbField } from './system-setting.constants' +import { + BILLING_PLAN_FIELD_DESCRIPTORS, + SYSTEM_SETTING_DEFINITIONS, + SYSTEM_SETTING_KEYS, +} from './system-setting.constants' import { SystemSettingStore } from './system-setting.store.service' import type { - SystemSettingField, SystemSettingOverview, SystemSettings, SystemSettingStats, + SystemSettingValueMap, UpdateSystemSettingsInput, } from './system-setting.types' import { SYSTEM_SETTING_UI_SCHEMA } from './system-setting.ui-schema' @@ -41,21 +58,6 @@ export class SystemSettingService { SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.schema, SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.defaultValue, ) - const maxPhotoUploadSizeMb = this.parseSetting( - rawValues[SYSTEM_SETTING_DEFINITIONS.maxPhotoUploadSizeMb.key], - SYSTEM_SETTING_DEFINITIONS.maxPhotoUploadSizeMb.schema, - SYSTEM_SETTING_DEFINITIONS.maxPhotoUploadSizeMb.defaultValue, - ) - const maxDataSyncObjectSizeMb = this.parseSetting( - rawValues[SYSTEM_SETTING_DEFINITIONS.maxDataSyncObjectSizeMb.key], - SYSTEM_SETTING_DEFINITIONS.maxDataSyncObjectSizeMb.schema, - SYSTEM_SETTING_DEFINITIONS.maxDataSyncObjectSizeMb.defaultValue, - ) - const maxPhotoLibraryItems = this.parseSetting( - rawValues[SYSTEM_SETTING_DEFINITIONS.maxPhotoLibraryItems.key], - SYSTEM_SETTING_DEFINITIONS.maxPhotoLibraryItems.schema, - SYSTEM_SETTING_DEFINITIONS.maxPhotoLibraryItems.defaultValue, - ) const localProviderEnabled = this.parseSetting( rawValues[SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.key], @@ -99,12 +101,24 @@ export class SystemSettingService { SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.schema, SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue, ) + const billingPlanOverrides = this.parseSetting( + rawValues[SYSTEM_SETTING_DEFINITIONS.billingPlanOverrides.key], + BILLING_PLAN_OVERRIDES_SCHEMA, + {}, + ) as BillingPlanOverrides + const billingPlanProducts = this.parseSetting( + rawValues[SYSTEM_SETTING_DEFINITIONS.billingPlanProducts.key], + BILLING_PLAN_PRODUCTS_SCHEMA, + {}, + ) as BillingPlanProductConfigs + const billingPlanPricing = this.parseSetting( + rawValues[SYSTEM_SETTING_DEFINITIONS.billingPlanPricing.key], + BILLING_PLAN_PRICING_SCHEMA, + {}, + ) as BillingPlanPricingConfigs return { allowRegistration, maxRegistrableUsers, - maxPhotoUploadSizeMb, - maxDataSyncObjectSizeMb, - maxPhotoLibraryItems, localProviderEnabled, baseDomain, oauthGatewayUrl, @@ -112,28 +126,34 @@ export class SystemSettingService { oauthGoogleClientSecret, oauthGithubClientId, oauthGithubClientSecret, + billingPlanOverrides, + billingPlanProducts, + billingPlanPricing, } } async getBillingPlanOverrides(): Promise { - const raw = await this.systemSettingStore.getRaw(BILLING_PLAN_OVERRIDES_SETTING_KEY) - const parsed = BILLING_PLAN_OVERRIDES_SCHEMA.safeParse(raw) - return parsed.success ? (parsed.data as BillingPlanOverrides) : {} + const settings = await this.getSettings() + return settings.billingPlanOverrides ?? {} } - async getStats(): Promise { + async getBillingPlanProducts(): Promise { const settings = await this.getSettings() - const totalUsers = await this.getTotalUserCount() - return this.buildStats(settings, totalUsers) + return settings.billingPlanProducts ?? {} + } + + async getBillingPlanPricing(): Promise { + const settings = await this.getSettings() + return settings.billingPlanPricing ?? {} } async getOverview(): Promise { - const values = await this.getSettings() + const settings = await this.getSettings() const totalUsers = await this.getTotalUserCount() - const stats = this.buildStats(values, totalUsers) + const stats = this.buildStats(settings, totalUsers) return { schema: SYSTEM_SETTING_UI_SCHEMA, - values, + values: this.buildValueMap(settings), stats, } } @@ -144,11 +164,16 @@ export class SystemSettingService { } const current = await this.getSettings() - const updates: Array<{ field: SystemSettingField; value: SystemSettings[SystemSettingField] }> = [] + const planFieldUpdates = this.extractPlanFieldUpdates(patch) + if (planFieldUpdates.hasUpdates) { + await this.applyPlanFieldUpdates(current, planFieldUpdates) + } - const enqueueUpdate = (field: K, value: SystemSettings[K]) => { + const updates: Array<{ field: SystemSettingDbField; value: unknown }> = [] + + const enqueueUpdate = (field: K, value: unknown) => { updates.push({ field, value }) - current[field] = value + ;(current as unknown as Record)[field] = value } if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) { @@ -175,30 +200,6 @@ export class SystemSettingService { } } - if (patch.maxPhotoUploadSizeMb !== undefined) { - const normalized = - patch.maxPhotoUploadSizeMb === null ? null : Math.max(1, Math.trunc(patch.maxPhotoUploadSizeMb)) - if (normalized !== current.maxPhotoUploadSizeMb) { - enqueueUpdate('maxPhotoUploadSizeMb', normalized) - } - } - - if (patch.maxDataSyncObjectSizeMb !== undefined) { - const normalized = - patch.maxDataSyncObjectSizeMb === null ? null : Math.max(1, Math.trunc(patch.maxDataSyncObjectSizeMb)) - if (normalized !== current.maxDataSyncObjectSizeMb) { - enqueueUpdate('maxDataSyncObjectSizeMb', normalized) - } - } - - if (patch.maxPhotoLibraryItems !== undefined) { - const normalized = - patch.maxPhotoLibraryItems === null ? null : Math.max(0, Math.trunc(patch.maxPhotoLibraryItems)) - if (normalized !== current.maxPhotoLibraryItems) { - enqueueUpdate('maxPhotoLibraryItems', normalized) - } - } - if (patch.baseDomain !== undefined) { const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase() if (!sanitized) { @@ -251,7 +252,7 @@ export class SystemSettingService { const definition = SYSTEM_SETTING_DEFINITIONS[entry.field] return { key: definition.key, - value: (entry.value ?? null) as SystemSettings[typeof entry.field] | null, + value: (entry.value ?? null) as unknown, options: { isSensitive: definition.isSensitive ?? false }, } }), @@ -260,6 +261,174 @@ export class SystemSettingService { return current } + private buildValueMap(settings: SystemSettings): SystemSettingValueMap { + const map = {} as SystemSettingValueMap + + ;(Object.keys(SYSTEM_SETTING_DEFINITIONS) as SystemSettingDbField[]).forEach((field) => { + ;(map as Record)[field] = settings[field] + }) + + const overrides = settings.billingPlanOverrides ?? {} + BILLING_PLAN_FIELD_DESCRIPTORS.quotas.forEach((descriptor) => { + const planOverrides = overrides[descriptor.planId] + if (planOverrides && descriptor.key in (planOverrides as object)) { + ;(map as Record)[descriptor.field] = + (planOverrides as BillingPlanQuota)[descriptor.key as keyof BillingPlanQuota] ?? null + } else { + ;(map as Record)[descriptor.field] = descriptor.defaultValue ?? null + } + }) + + const pricing = settings.billingPlanPricing ?? {} + BILLING_PLAN_FIELD_DESCRIPTORS.pricing.forEach((descriptor) => { + const entry = pricing[descriptor.planId] + if (descriptor.key === 'currency') { + ;(map as Record)[descriptor.field] = entry?.currency ?? null + } else if (descriptor.key === 'monthlyPrice') { + ;(map as Record)[descriptor.field] = entry?.monthlyPrice ?? null + } + }) + + const products = settings.billingPlanProducts ?? {} + BILLING_PLAN_FIELD_DESCRIPTORS.payment.forEach((descriptor) => { + const entry = products[descriptor.planId] + ;(map as Record)[descriptor.field] = entry?.creemProductId ?? null + }) + + return map + } + + private extractPlanFieldUpdates(patch: UpdateSystemSettingsInput): PlanFieldUpdateSummary { + const summary: PlanFieldUpdateSummary = { + hasUpdates: false, + quotas: {}, + pricing: {}, + products: {}, + } + + for (const descriptor of BILLING_PLAN_FIELD_DESCRIPTORS.quotas) { + if (!(descriptor.field in patch)) { + continue + } + summary.hasUpdates = true + const raw = patch[descriptor.field] + delete (patch as Record)[descriptor.field] + const numericValue = raw === null || raw === undefined ? null : typeof raw === 'number' ? raw : Number(raw) + + const planPatch = summary.quotas[descriptor.planId] ?? {} + planPatch[descriptor.key as keyof BillingPlanQuota] = + numericValue === null || Number.isNaN(numericValue) ? null : numericValue + summary.quotas[descriptor.planId] = planPatch + } + + for (const descriptor of BILLING_PLAN_FIELD_DESCRIPTORS.pricing) { + if (!(descriptor.field in patch)) { + continue + } + summary.hasUpdates = true + const raw = patch[descriptor.field] + delete (patch as Record)[descriptor.field] + const planPatch = summary.pricing[descriptor.planId] ?? {} + + if (descriptor.key === 'currency') { + const normalized = + raw === null || raw === undefined + ? null + : typeof raw === 'string' + ? this.normalizeNullableString(raw) + : this.normalizeNullableString(String(raw)) + planPatch.currency = normalized + } else if (descriptor.key === 'monthlyPrice') { + const numericValue = raw === null || raw === undefined ? null : typeof raw === 'number' ? raw : Number(raw) + planPatch.monthlyPrice = numericValue === null || Number.isNaN(numericValue) ? null : numericValue + } + + summary.pricing[descriptor.planId] = planPatch + } + + for (const descriptor of BILLING_PLAN_FIELD_DESCRIPTORS.payment) { + if (!(descriptor.field in patch)) { + continue + } + summary.hasUpdates = true + const raw = patch[descriptor.field] + delete (patch as Record)[descriptor.field] + const normalized = + raw === null || raw === undefined + ? null + : typeof raw === 'string' + ? this.normalizeNullableString(raw) + : this.normalizeNullableString(String(raw)) + summary.products[descriptor.planId] = { creemProductId: normalized } + } + + return summary + } + + private async applyPlanFieldUpdates(current: SystemSettings, updates: PlanFieldUpdateSummary): Promise { + if (!updates.hasUpdates) { + return + } + + if (Object.keys(updates.quotas).length > 0) { + const nextOverrides: BillingPlanOverrides = structuredClone(current.billingPlanOverrides ?? {}) + for (const [planId, quotaPatch] of Object.entries(updates.quotas) as Array<[ + BillingPlanId, + Partial, + ]>) { + const existing = { ...nextOverrides[planId] } + for (const [quotaKey, value] of Object.entries(quotaPatch) as Array<[keyof BillingPlanQuota, number | null]>) { + if (value === null || value === undefined || Number.isNaN(value)) { + delete existing[quotaKey] + } else { + existing[quotaKey] = value + } + } + if (Object.keys(existing).length === 0) { + delete nextOverrides[planId] + } else { + nextOverrides[planId] = existing + } + } + await this.systemSettingStore.set(BILLING_PLAN_OVERRIDES_SETTING_KEY, nextOverrides) + current.billingPlanOverrides = nextOverrides + } + + if (Object.keys(updates.pricing).length > 0) { + const nextPricing: BillingPlanPricingConfigs = structuredClone(current.billingPlanPricing ?? {}) + for (const [planId, pricingPatch] of Object.entries(updates.pricing) as Array<[ + BillingPlanId, + Partial, + ]>) { + const entry: BillingPlanPricing = { + monthlyPrice: pricingPatch.monthlyPrice ?? null, + currency: pricingPatch.currency ?? null, + } + if (entry.monthlyPrice === null && !entry.currency) { + delete nextPricing[planId] + } else { + nextPricing[planId] = entry + } + } + await this.systemSettingStore.set(BILLING_PLAN_PRICING_SETTING_KEY, nextPricing) + current.billingPlanPricing = nextPricing + } + + if (Object.keys(updates.products).length > 0) { + const nextProducts: BillingPlanProductConfigs = structuredClone(current.billingPlanProducts ?? {}) + for (const [planId, product] of Object.entries(updates.products) as Array<[BillingPlanId, BillingPlanPaymentInfo]>) { + const normalized = this.normalizeNullableString(product.creemProductId) + if (!normalized) { + delete nextProducts[planId] + } else { + nextProducts[planId] = { creemProductId: normalized } + } + } + await this.systemSettingStore.set(BILLING_PLAN_PRODUCTS_SETTING_KEY, nextProducts) + current.billingPlanProducts = nextProducts + } + } + async ensureRegistrationAllowed(): Promise { const settings = await this.getSettings() @@ -381,3 +550,27 @@ const PLAN_OVERRIDE_ENTRY_SCHEMA = z.object({ }) const BILLING_PLAN_OVERRIDES_SCHEMA = z.record(z.string(), PLAN_OVERRIDE_ENTRY_SCHEMA).default({}) + +const PLAN_PRODUCT_ENTRY_SCHEMA = z.object({ + creemProductId: z.string().trim().min(1).optional(), +}) + +const BILLING_PLAN_PRODUCTS_SCHEMA = z.record(z.string(), PLAN_PRODUCT_ENTRY_SCHEMA).default({}) + +const PLAN_PRICING_ENTRY_SCHEMA = z.object({ + monthlyPrice: z.number().min(0).nullable().optional(), + currency: z.string().trim().min(1).nullable().optional(), +}) + +const BILLING_PLAN_PRICING_SCHEMA = z.record(z.string(), PLAN_PRICING_ENTRY_SCHEMA).default({}) + +type PlanQuotaUpdateMap = Partial>> +type PlanPricingUpdateMap = Partial>> +type PlanProductUpdateMap = Partial> + +interface PlanFieldUpdateSummary { + hasUpdates: boolean + quotas: PlanQuotaUpdateMap + pricing: PlanPricingUpdateMap + products: PlanProductUpdateMap +} diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts index fa2ee615..1d579d0a 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.types.ts @@ -1,13 +1,15 @@ +import type { + BillingPlanOverrides, + BillingPlanPricingConfigs, + BillingPlanProductConfigs, +} from 'core/modules/platform/billing/billing-plan.types' import type { UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type' -import type { SystemSettingField } from './system-setting.constants' +import type { BillingPlanSettingField, SystemSettingDbField, SystemSettingField } from './system-setting.constants' export interface SystemSettings { allowRegistration: boolean maxRegistrableUsers: number | null - maxPhotoUploadSizeMb: number | null - maxDataSyncObjectSizeMb: number | null - maxPhotoLibraryItems: number | null localProviderEnabled: boolean baseDomain: string oauthGatewayUrl: string | null @@ -15,11 +17,14 @@ export interface SystemSettings { oauthGoogleClientSecret: string | null oauthGithubClientId: string | null oauthGithubClientSecret: string | null + billingPlanOverrides: BillingPlanOverrides + billingPlanProducts: BillingPlanProductConfigs + billingPlanPricing: BillingPlanPricingConfigs } export type SystemSettingValueMap = { - [K in SystemSettingField]: SystemSettings[K] -} + [K in SystemSettingDbField]: SystemSettings[K] +} & Partial> export interface SystemSettingStats { totalUsers: number @@ -32,6 +37,7 @@ export interface SystemSettingOverview { stats: SystemSettingStats } -export type UpdateSystemSettingsInput = Partial +export type UpdateSystemSettingsInput = Partial & + Partial> export { type SystemSettingField } from './system-setting.constants' diff --git a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts index df7d202a..93c8a7f2 100644 --- a/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts +++ b/be/apps/core/src/modules/configuration/system-setting/system-setting.ui-schema.ts @@ -1,9 +1,113 @@ +import { BILLING_PLAN_DEFINITIONS, BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants' import type { UiNode, UiSchema } from 'core/modules/ui/ui-schema/ui-schema.type' import type { SystemSettingField } from './system-setting.constants' export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.4.0' +const PLAN_QUOTA_FIELDS = [ + { + suffix: 'monthlyAssetProcessLimit', + title: '每月可新增照片 (张)', + description: '达到上限后将阻止新增照片。留空表示回退到默认值或不限。', + placeholder: '例如 300', + }, + { + suffix: 'libraryItemLimit', + title: '图库容量限制 (张)', + description: '限制单个租户可管理的照片数量,0 表示完全禁止新增。', + placeholder: '例如 500', + }, + { + suffix: 'maxUploadSizeMb', + title: '后台上传大小上限 (MB)', + description: '单次上传的最大文件体积,留空表示默认值或无限制。', + placeholder: '例如 20', + }, + { + suffix: 'maxSyncObjectSizeMb', + title: '同步素材大小上限 (MB)', + description: 'Data Sync 导入时允许的最大文件尺寸。', + placeholder: '例如 50', + }, +] as const + +const PLAN_PRICING_FIELDS = [ + { + suffix: 'monthlyPrice', + title: '月度定价', + description: '用于展示的价格或 Creem 产品价格,留空保留默认值。', + placeholder: '例如 49', + }, + { + suffix: 'currency', + title: '币种', + description: 'ISO 货币代码,如 CNY、USD 等。', + placeholder: 'CNY', + }, +] as const + +const PLAN_PAYMENT_FIELDS = [ + { + suffix: 'creemProductId', + title: 'Creem Product ID', + description: '用于创建结算会话的 Creem 商品 ID。留空表示该计划不会显示升级入口。', + placeholder: 'prod_xxx', + }, +] as const + +const BILLING_PLAN_GROUPS: ReadonlyArray> = BILLING_PLAN_IDS.map((planId) => { + const metadata = BILLING_PLAN_DEFINITIONS[planId] + const quotaFields = PLAN_QUOTA_FIELDS.map((field) => ({ + type: 'field' as const, + id: `${planId}-${field.suffix}`, + title: field.title, + description: field.description, + helperText: '留空表示遵循默认或不限,填写数字后将覆盖对应计划。', + key: `billingPlan.${planId}.quota.${field.suffix}` as SystemSettingField, + component: { + type: 'text' as const, + inputType: 'number' as const, + placeholder: field.placeholder, + }, + })) + + const pricingFields = PLAN_PRICING_FIELDS.map((field) => ({ + type: 'field' as const, + id: `${planId}-pricing-${field.suffix}`, + title: field.title, + description: field.description, + helperText: field.suffix === 'monthlyPrice' ? '留空表示暂不展示定价信息。' : '留空表示使用默认币种或不展示。', + key: `billingPlan.${planId}.pricing.${field.suffix}` as SystemSettingField, + component: { + type: 'text' as const, + inputType: field.suffix === 'monthlyPrice' ? ('number' as const) : ('text' as const), + placeholder: field.placeholder, + }, + })) + + const paymentFields = PLAN_PAYMENT_FIELDS.map((field) => ({ + type: 'field' as const, + id: `${planId}-payment-${field.suffix}`, + title: field.title, + description: field.description, + helperText: '为空将隐藏升级入口。', + key: `billingPlan.${planId}.payment.${field.suffix}` as SystemSettingField, + component: { + type: 'text' as const, + placeholder: field.placeholder, + }, + })) + + return { + type: 'group' as const, + id: `billing-plan-${planId}`, + title: `${metadata.name} (${planId})`, + description: metadata.description, + children: [...quotaFields, ...pricingFields, ...paymentFields], + } satisfies UiNode +}) + export const SYSTEM_SETTING_UI_SCHEMA: UiSchema = { version: SYSTEM_SETTING_UI_SCHEMA_VERSION, title: '系统设置', @@ -56,7 +160,7 @@ export const SYSTEM_SETTING_UI_SCHEMA: UiSchema = { helperText: '设置为 0 时将立即阻止新的用户注册。', key: 'maxRegistrableUsers', component: { - type: 'text', + type: 'text' as const, inputType: 'number', placeholder: '无限制', }, @@ -65,51 +169,11 @@ export const SYSTEM_SETTING_UI_SCHEMA: UiSchema = { }, { type: 'section', - id: 'photo-constraints', - title: '照片库资源限制', - description: '统一设置照片上传、同步及照片总量的上限,确保资源消耗在可控范围内。', - icon: 'image-up', - children: [ - { - type: 'field', - id: 'photo-upload-max-size', - title: '单张上传大小上限 (MB)', - description: '限制用户通过后台上传的照片文件体积,超出限制将被拒绝。', - helperText: '留空表示不限制,最小值 1 MB。', - key: 'maxPhotoUploadSizeMb', - component: { - type: 'text', - inputType: 'number', - placeholder: '无限制', - }, - }, - { - type: 'field', - id: 'photo-sync-max-size', - title: 'Data Sync 单文件上限 (MB)', - description: '控制数据同步时允许导入的存储文件大小,避免超大素材拖慢同步。', - helperText: '留空表示不限制,最小值 1 MB。', - key: 'maxDataSyncObjectSizeMb', - component: { - type: 'text', - inputType: 'number', - placeholder: '无限制', - }, - }, - { - type: 'field', - id: 'photo-library-max-items', - title: '单租户可管理照片数量', - description: '达到上限后用户无法再新增图片,可留空表示不限制。', - helperText: '设置为 0 将阻止任何新增图片。', - key: 'maxPhotoLibraryItems', - component: { - type: 'text', - inputType: 'number', - placeholder: '无限制', - }, - }, - ], + id: 'billing-plan-settings', + title: '订阅计划配置', + description: '为每个订阅计划定义资源限制、展示价格以及 Creem 商品映射。', + icon: 'badge-dollar-sign', + children: BILLING_PLAN_GROUPS, }, { type: 'section', diff --git a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts index bf52664e..a89c41c4 100644 --- a/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts +++ b/be/apps/core/src/modules/content/photo/assets/photo-asset.service.ts @@ -12,7 +12,6 @@ import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } f import { EventEmitterService } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' -import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' import { runWithBuilderLogRelay } from 'core/modules/infrastructure/data-sync/builder-log-relay' import type { DataSyncAction, @@ -94,7 +93,6 @@ export class PhotoAssetService { private readonly dbAccessor: DbAccessor, private readonly photoBuilderService: PhotoBuilderService, private readonly photoStorageService: PhotoStorageService, - private readonly systemSettingService: SystemSettingService, private readonly billingPlanService: BillingPlanService, private readonly billingUsageService: BillingUsageService, ) {} @@ -264,9 +262,8 @@ export class PhotoAssetService { const tenant = requireTenantContext() const db = this.dbAccessor.get() - const systemSettings = await this.systemSettingService.getSettings() const planQuota = await this.billingPlanService.getQuotaForTenant(tenant.tenant.id) - const uploadSizeLimit = planQuota.maxUploadSizeMb ?? systemSettings.maxPhotoUploadSizeMb + const uploadSizeLimit = planQuota.maxUploadSizeMb this.enforceUploadSizeLimit(inputs, uploadSizeLimit) const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id) @@ -333,7 +330,7 @@ export class PhotoAssetService { const pendingPhotoPlans = photoPlans.filter((plan) => !existingPhotoKeySet.has(plan.storageKey)) await this.billingPlanService.ensurePhotoProcessingAllowance(tenant.tenant.id, pendingPhotoPlans.length) - const libraryLimit = planQuota.libraryItemLimit ?? systemSettings.maxPhotoLibraryItems + const libraryLimit = planQuota.libraryItemLimit await this.ensurePhotoLibraryCapacity(tenant.tenant.id, db, pendingPhotoPlans.length, libraryLimit) throwIfAborted() diff --git a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts index ee4611ca..df619a00 100644 --- a/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts +++ b/be/apps/core/src/modules/infrastructure/data-sync/data-sync.service.ts @@ -4,7 +4,6 @@ import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets, ph import { createLogger, EventEmitterService } from '@afilmory/framework' import { DbAccessor } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' -import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service' import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service' import { BILLING_USAGE_EVENT } from 'core/modules/platform/billing/billing.constants' @@ -78,7 +77,6 @@ export class DataSyncService { private readonly dbAccessor: DbAccessor, private readonly photoBuilderService: PhotoBuilderService, private readonly photoStorageService: PhotoStorageService, - private readonly systemSettingService: SystemSettingService, private readonly billingPlanService: BillingPlanService, private readonly billingUsageService: BillingUsageService, ) {} @@ -90,13 +88,12 @@ export class DataSyncService { async runSync(options: DataSyncOptions, onProgress?: DataSyncProgressEmitter): Promise { const tenant = requireTenantContext() const runStartedAt = new Date() - const systemSettings = await this.systemSettingService.getSettings() const planQuota = await this.billingPlanService.getQuotaForTenant(tenant.tenant.id) - const effectiveMaxObjectMb = planQuota.maxSyncObjectSizeMb ?? systemSettings.maxDataSyncObjectSizeMb + const effectiveMaxObjectMb = planQuota.maxSyncObjectSizeMb const syncLimits = { maxObjectBytes: this.convertMbToBytes(effectiveMaxObjectMb), maxObjectSizeMb: effectiveMaxObjectMb, - libraryLimit: planQuota.libraryItemLimit ?? systemSettings.maxPhotoLibraryItems, + libraryLimit: planQuota.libraryItemLimit, } const { builderConfig, storageConfig } = await this.resolveBuilderConfigForTenant(tenant.tenant.id, options) const context = await this.prepareSyncContext(tenant.tenant.id, builderConfig, storageConfig) diff --git a/be/apps/core/src/modules/platform/auth/auth.controller.ts b/be/apps/core/src/modules/platform/auth/auth.controller.ts index 09405599..33ef5fb5 100644 --- a/be/apps/core/src/modules/platform/auth/auth.controller.ts +++ b/be/apps/core/src/modules/platform/auth/auth.controller.ts @@ -149,7 +149,11 @@ export class AuthController { return { user: authContext.user, session: authContext.session, - tenant: tenantContext, + tenant: { + isPlaceholder: tenantContext.isPlaceholder, + requestedSlug: tenantContext.requestedSlug, + ...tenantContext.tenant, + }, } } diff --git a/be/apps/core/src/modules/platform/auth/auth.provider.ts b/be/apps/core/src/modules/platform/auth/auth.provider.ts index 0a400329..60db7531 100644 --- a/be/apps/core/src/modules/platform/auth/auth.provider.ts +++ b/be/apps/core/src/modules/platform/auth/auth.provider.ts @@ -1,8 +1,10 @@ import { createHash } from 'node:crypto' import { authAccounts, authSessions, authUsers, authVerifications, generateId } from '@afilmory/db' +import { env } from '@afilmory/env' import type { OnModuleInit } from '@afilmory/framework' import { createLogger, HttpContext } from '@afilmory/framework' +import { creem } from '@creem_io/better-auth' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { APIError, createAuthMiddleware } from 'better-auth/api' @@ -10,6 +12,9 @@ import { admin } from 'better-auth/plugins' import { DrizzleProvider } from 'core/database/database.provider' import { BizException } from 'core/errors' import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' +import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.constants' +import { BillingPlanService } from 'core/modules/platform/billing/billing-plan.service' +import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types' import type { Context } from 'hono' import { injectable } from 'tsyringe' @@ -33,6 +38,7 @@ export class AuthProvider implements OnModuleInit { private readonly drizzleProvider: DrizzleProvider, private readonly systemSettings: SystemSettingService, private readonly tenantService: TenantService, + private readonly billingPlanService: BillingPlanService, ) {} async onModuleInit(): Promise { @@ -294,6 +300,70 @@ export class AuthProvider implements OnModuleInit { defaultRole: 'user', defaultBanReason: 'Spamming', }), + creem({ + apiKey: env.CREEM_API_KEY, + webhookSecret: env.CREEM_WEBHOOK_SECRET, + persistSubscriptions: true, + schema: { + user: { + modelName: 'auth_user', + fields: { + creemCustomerId: { + type: 'string', + fieldName: 'creem_customer_id', + }, + }, + }, + subscription: { + modelName: 'creem_subscription', + fields: { + productId: { + type: 'string', + fieldName: 'product_id', + }, + referenceId: { + type: 'string', + fieldName: 'reference_id', + }, + creemCustomerId: { + type: 'string', + fieldName: 'creem_customer_id', + }, + creemSubscriptionId: { + type: 'string', + fieldName: 'creem_subscription_id', + }, + creemOrderId: { + type: 'string', + fieldName: 'creem_order_id', + }, + status: { + type: 'string', + fieldName: 'status', + }, + periodStart: { + type: 'string', + fieldName: 'period_start', + }, + periodEnd: { + type: 'string', + fieldName: 'period_end', + }, + cancelAtPeriodEnd: { + type: 'boolean', + fieldName: 'cancel_at_period_end', + }, + }, + }, + }, + testMode: env.NODE_ENV !== 'production', + onGrantAccess: async ({ metadata }) => { + await this.handleCreemGrant(metadata) + }, + onRevokeAccess: async ({ metadata }) => { + await this.handleCreemRevoke(metadata) + }, + }), ], hooks: { before: createAuthMiddleware(async (ctx) => { @@ -367,6 +437,61 @@ export class AuthProvider implements OnModuleInit { return hash.digest('hex') } + private async handleCreemGrant(metadata?: Record): Promise { + const tenantId = this.extractMetadataValue(metadata, 'tenantId') + const planId = this.extractPlanIdFromMetadata(metadata) + + if (!tenantId || !planId) { + logger.warn('[AuthProvider] Creem grant event missing tenantId or planId metadata') + return + } + + try { + await this.billingPlanService.updateTenantPlan(tenantId, planId) + logger.info(`[AuthProvider] Tenant ${tenantId} upgraded to ${planId} via Creem`) + } catch (error) { + logger.error(`[AuthProvider] Failed to update tenant ${tenantId} plan from Creem grant`, error) + } + } + + private async handleCreemRevoke(metadata?: Record): Promise { + const tenantId = this.extractMetadataValue(metadata, 'tenantId') + if (!tenantId) { + logger.warn('[AuthProvider] Creem revoke event missing tenantId metadata') + return + } + + try { + await this.billingPlanService.updateTenantPlan(tenantId, 'free') + logger.info(`[AuthProvider] Tenant ${tenantId} downgraded to free via Creem revoke`) + } catch (error) { + logger.error(`[AuthProvider] Failed to downgrade tenant ${tenantId} after Creem revoke`, error) + } + } + + private extractPlanIdFromMetadata(metadata?: Record): BillingPlanId | null { + const planId = this.extractMetadataValue(metadata, 'planId') + if (!planId) { + return null + } + if (BILLING_PLAN_IDS.includes(planId as BillingPlanId)) { + return planId as BillingPlanId + } + return null + } + + private extractMetadataValue(metadata: Record | undefined, key: string): string | null { + if (!metadata) { + return null + } + const raw = metadata[key] + if (typeof raw !== 'string') { + return null + } + const trimmed = raw.trim() + return trimmed.length > 0 ? trimmed : null + } + async handler(context: Context): Promise { const requestPath = typeof context.req.path === 'string' ? context.req.path : new URL(context.req.url).pathname if (requestPath.startsWith('/api/auth/error')) { diff --git a/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts b/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts index 7a92a98f..64015f6a 100644 --- a/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts +++ b/be/apps/core/src/modules/platform/billing/billing-plan.constants.ts @@ -1,7 +1,6 @@ import type { BillingPlanDefinition, BillingPlanId } from './billing-plan.types' export const BILLING_PLAN_IDS: readonly BillingPlanId[] = ['free', 'pro', 'friend'] -export const PUBLIC_PLAN_IDS: readonly BillingPlanId[] = ['free'] export const BILLING_PLAN_DEFINITIONS: Record = { free: { @@ -28,7 +27,7 @@ export const BILLING_PLAN_DEFINITIONS: Record { - const planId = await this.resolvePlanIdForTenant(tenantId) + const [planId, overrides, productConfigs, pricingConfigs] = await Promise.all([ + this.resolvePlanIdForTenant(tenantId), + this.getPlanOverrides(), + this.getPlanProducts(), + this.getPlanPricing(), + ]) + const definition = BILLING_PLAN_DEFINITIONS[planId] - const overrides = await this.getPlanOverrides() const quotas = this.applyOverrides(definition.quotas, overrides[planId]) return { planId, name: definition.name, description: definition.description, quotas, + payment: this.buildPaymentInfo(productConfigs[planId]), + pricing: this.buildPricingInfo(pricingConfigs[planId]), } } async getPublicPlanSummaries(): Promise { - const overrides = await this.getPlanOverrides() - return PUBLIC_PLAN_IDS.map((id) => { + const [overrides, productConfigs, pricingConfigs] = await Promise.all([ + this.getPlanOverrides(), + this.getPlanProducts(), + this.getPlanPricing(), + ]) + return BILLING_PLAN_IDS.map((id) => { const definition = BILLING_PLAN_DEFINITIONS[id] return { planId: id, name: definition.name, description: definition.description, quotas: this.applyOverrides(definition.quotas, overrides[id]), + payment: this.buildPaymentInfo(productConfigs[id]), + pricing: this.buildPricingInfo(pricingConfigs[id]), } - }) + }).filter((plan) => this.shouldExposePlan(plan.planId, plan.payment)) } async ensurePhotoProcessingAllowance(tenantId: string, incomingItems: number): Promise { @@ -129,6 +146,14 @@ export class BillingPlanService { return await this.systemSettingService.getBillingPlanOverrides() } + private async getPlanProducts(): Promise { + return await this.systemSettingService.getBillingPlanProducts() + } + + private async getPlanPricing(): Promise { + return await this.systemSettingService.getBillingPlanPricing() + } + private applyOverrides(base: BillingPlanQuota, override?: BillingPlanQuotaOverride): BillingPlanQuota { if (!override) { return { ...base } @@ -144,6 +169,48 @@ export class BillingPlanService { override.maxSyncObjectSizeMb !== undefined ? override.maxSyncObjectSizeMb : base.maxSyncObjectSizeMb, } } + + private buildPaymentInfo(entry?: BillingPlanPaymentInfo): BillingPlanPaymentInfo | undefined { + if (!entry) { + return undefined + } + const creemProductId = this.normalizeString(entry.creemProductId) + if (!creemProductId) { + return undefined + } + return { creemProductId } + } + + private shouldExposePlan(planId: BillingPlanId, payment?: BillingPlanPaymentInfo): boolean { + if (planId === 'free') { + return true + } + + return Boolean(payment?.creemProductId) + } + + private buildPricingInfo(entry?: BillingPlanPricing): BillingPlanPricing | undefined { + if (!entry) { + return undefined + } + const hasPrice = entry.monthlyPrice !== null && !Number.isNaN(entry.monthlyPrice ?? undefined) + const hasCurrency = !!entry.currency + if (!hasPrice && !hasCurrency) { + return undefined + } + return { + monthlyPrice: hasPrice ? entry.monthlyPrice : null, + currency: entry.currency ?? null, + } + } + + private normalizeString(value?: string | null): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null + } } export interface BillingPlanSummary { @@ -151,4 +218,6 @@ export interface BillingPlanSummary { name: string description: string quotas: BillingPlanQuota + pricing?: BillingPlanPricing + payment?: BillingPlanPaymentInfo } diff --git a/be/apps/core/src/modules/platform/billing/billing-plan.types.ts b/be/apps/core/src/modules/platform/billing/billing-plan.types.ts index 2cca926f..eef74a6b 100644 --- a/be/apps/core/src/modules/platform/billing/billing-plan.types.ts +++ b/be/apps/core/src/modules/platform/billing/billing-plan.types.ts @@ -17,3 +17,16 @@ export interface BillingPlanDefinition { export type BillingPlanQuotaOverride = Partial export type BillingPlanOverrides = Record + +export interface BillingPlanPaymentInfo { + creemProductId?: string | null +} + +export type BillingPlanProductConfigs = Record + +export interface BillingPlanPricing { + monthlyPrice: number | null + currency: string | null +} + +export type BillingPlanPricingConfigs = Record diff --git a/be/apps/core/src/modules/platform/billing/billing.controller.ts b/be/apps/core/src/modules/platform/billing/billing.controller.ts index 852d6f1b..2e3508c1 100644 --- a/be/apps/core/src/modules/platform/billing/billing.controller.ts +++ b/be/apps/core/src/modules/platform/billing/billing.controller.ts @@ -9,7 +9,7 @@ import type { BillingUsageOverview } from './billing-usage.service' import { BillingUsageService } from './billing-usage.service' const usageQuerySchema = z.object({ - limit: z.number().optional(), + limit: z.coerce.number().positive().int().optional().default(10), }) class UsageQueryDto extends createZodSchemaDto(usageQuerySchema) {} diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts index 20b0b33c..88e3b9d1 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin.dto.ts @@ -3,13 +3,38 @@ import { BILLING_PLAN_IDS } from 'core/modules/platform/billing/billing-plan.con import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types' import { z } from 'zod' +const planQuotaFields = (() => { + const fields: Record = {} + for (const planId of BILLING_PLAN_IDS) { + fields[`billingPlan.${planId}.quota.monthlyAssetProcessLimit`] = z.number().int().min(0).nullable().optional() + fields[`billingPlan.${planId}.quota.libraryItemLimit`] = z.number().int().min(0).nullable().optional() + fields[`billingPlan.${planId}.quota.maxUploadSizeMb`] = z.number().int().min(1).nullable().optional() + fields[`billingPlan.${planId}.quota.maxSyncObjectSizeMb`] = z.number().int().min(1).nullable().optional() + } + return fields +})() + +const planPricingFields = (() => { + const fields: Record = {} + for (const planId of BILLING_PLAN_IDS) { + fields[`billingPlan.${planId}.pricing.monthlyPrice`] = z.number().min(0).nullable().optional() + fields[`billingPlan.${planId}.pricing.currency`] = z.string().trim().min(1).nullable().optional() + } + return fields +})() + +const planProductFields = (() => { + const fields: Record = {} + for (const planId of BILLING_PLAN_IDS) { + fields[`billingPlan.${planId}.payment.creemProductId`] = z.string().trim().min(1).nullable().optional() + } + return fields +})() + const updateSuperAdminSettingsSchema = z .object({ allowRegistration: z.boolean().optional(), maxRegistrableUsers: z.number().int().min(0).nullable().optional(), - maxPhotoUploadSizeMb: z.number().int().positive().nullable().optional(), - maxDataSyncObjectSizeMb: z.number().int().positive().nullable().optional(), - maxPhotoLibraryItems: z.number().int().min(0).nullable().optional(), localProviderEnabled: z.boolean().optional(), baseDomain: z .string() @@ -30,6 +55,9 @@ const updateSuperAdminSettingsSchema = z oauthGoogleClientSecret: z.string().trim().min(1).nullable().optional(), oauthGithubClientId: z.string().trim().min(1).nullable().optional(), oauthGithubClientSecret: z.string().trim().min(1).nullable().optional(), + ...planQuotaFields, + ...planPricingFields, + ...planProductFields, }) .refine((value) => Object.values(value).some((entry) => entry !== undefined), { message: '至少需要更新一项设置', diff --git a/be/apps/dashboard/package.json b/be/apps/dashboard/package.json index c1567cd1..c96c810a 100644 --- a/be/apps/dashboard/package.json +++ b/be/apps/dashboard/package.json @@ -19,6 +19,7 @@ "@afilmory/hooks": "workspace:*", "@afilmory/ui": "workspace:*", "@afilmory/utils": "workspace:*", + "@creem_io/better-auth": "0.0.8", "@essentials/request-timeout": "1.3.0", "@headlessui/react": "2.2.9", "@pastel-palette/tailwindcss": "1.0.0-canary.3", diff --git a/be/apps/dashboard/public/creem-redirect.html b/be/apps/dashboard/public/creem-redirect.html new file mode 100644 index 00000000..60133b47 --- /dev/null +++ b/be/apps/dashboard/public/creem-redirect.html @@ -0,0 +1,100 @@ + + + + + + Finishing checkout… + + + +
+

Finishing checkout

+

Redirecting you back to your workspace…

+ +
+ + + diff --git a/be/apps/dashboard/src/modules/auth/api/session.ts b/be/apps/dashboard/src/modules/auth/api/session.ts index 52c52a3a..3bf4b0c8 100644 --- a/be/apps/dashboard/src/modules/auth/api/session.ts +++ b/be/apps/dashboard/src/modules/auth/api/session.ts @@ -4,9 +4,10 @@ import { camelCaseKeys } from '~/lib/case' import type { BetterAuthSession, BetterAuthUser } from '../types' export interface SessionTenant { + isPlaceholder: boolean + requestedSlug: string | null id: string slug: string | null - isPlaceholder: boolean } export type SessionResponse = { diff --git a/be/apps/dashboard/src/modules/auth/auth-client.ts b/be/apps/dashboard/src/modules/auth/auth-client.ts index 43753b4a..a5dde968 100644 --- a/be/apps/dashboard/src/modules/auth/auth-client.ts +++ b/be/apps/dashboard/src/modules/auth/auth-client.ts @@ -1,4 +1,5 @@ -import { createAuthClient } from 'better-auth/react' +import { creemClient } from '@creem_io/better-auth/client' +import { createCreemAuthClient } from '@creem_io/better-auth/create-creem-auth-client' import { FetchError } from 'ofetch' const apiBase = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api' @@ -11,8 +12,9 @@ const commonOptions = { }, } -export const authClient = createAuthClient({ +export const authClient = createCreemAuthClient({ baseURL: authBase, + plugins: [creemClient()], ...commonOptions, }) diff --git a/be/apps/dashboard/src/modules/billing/types.ts b/be/apps/dashboard/src/modules/billing/types.ts index aa9d944f..e1de1ff8 100644 --- a/be/apps/dashboard/src/modules/billing/types.ts +++ b/be/apps/dashboard/src/modules/billing/types.ts @@ -10,6 +10,13 @@ export interface BillingPlanSummary { name: string description: string quotas: BillingPlanQuota + pricing?: { + monthlyPrice: number | null + currency: string | null + } + payment?: { + creemProductId?: string | null + } } export interface BillingPlanResponse { diff --git a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx index 62cca240..cd3b5976 100644 --- a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx +++ b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx @@ -7,7 +7,7 @@ import { startTransition, useCallback, useEffect, useMemo, useRef, useState } fr import { LinearBorderPanel } from '~/components/common/GlassPanel' import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer' -import type { SchemaFormState, SchemaFormValue } from '../../schema-form/types' +import type { SchemaFormState, SchemaFormValue, UiNode } from '../../schema-form/types' import { useSuperAdminSettingsQuery, useUpdateSuperAdminSettingsMutation } from '../hooks' import type { SuperAdminSettingsResponse, UpdateSuperAdminSettingsPayload } from '../types' import type { SuperAdminFieldMap } from '../utils/schema-form-adapter' @@ -45,7 +45,11 @@ function extractRawSettings(payload: SuperAdminSettingsResponse): Record(() => new Map()) const [formState, setFormState] = useState(null) @@ -145,6 +149,19 @@ export function SuperAdminSettingsForm() { return '所有设置已同步' }, [hasChanges, updateMutation.error, updateMutation.isError, updateMutation.isPending, updateMutation.isSuccess]) + const shouldRenderNode = useMemo(() => { + if (!visibleSectionIds || visibleSectionIds.length === 0) { + return + } + const allowed = new Set(visibleSectionIds) + return (node: UiNode) => { + if (node.type === 'section') { + return allowed.has(node.id) + } + return true + } + }, [visibleSectionIds]) + if (isError) { return ( @@ -190,7 +207,12 @@ export function SuperAdminSettingsForm() { transition={Spring.presets.smooth} className="space-y-6" > - +
diff --git a/be/apps/dashboard/src/pages/(main)/plan.tsx b/be/apps/dashboard/src/pages/(main)/plan.tsx index f98a3a43..271f7793 100644 --- a/be/apps/dashboard/src/pages/(main)/plan.tsx +++ b/be/apps/dashboard/src/pages/(main)/plan.tsx @@ -1,8 +1,14 @@ import { Button } from '@afilmory/ui' -import { Spring } from '@afilmory/utils' -import { m } from 'motion/react' +import { clsxm } from '@afilmory/utils' +import { useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { LinearBorderPanel } from '~/components/common/GlassPanel' import { MainPageLayout } from '~/components/layouts/MainPageLayout' +import type { SessionResponse } from '~/modules/auth/api/session' +import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session' +import { authClient } from '~/modules/auth/auth-client' import type { BillingPlanSummary } from '~/modules/billing' import { useTenantPlanQuery } from '~/modules/billing' @@ -22,12 +28,30 @@ const QUOTA_UNITS: Record = { export function Component() { const planQuery = useTenantPlanQuery() + const queryClient = useQueryClient() + const session = (queryClient.getQueryData(AUTH_SESSION_QUERY_KEY) ?? + null) as SessionResponse | null + + const tenantId = session?.tenant?.id ?? null + const tenantSlug = session?.tenant?.slug ?? null const plan = planQuery.data?.plan ?? null const availablePlans = planQuery.data?.availablePlans ?? [] + const plans = useMemo(() => { + if (!plan) { + return [] + } + const merged = new Map() + for (const candidate of [plan, ...availablePlans]) { + if (candidate && !merged.has(candidate.planId)) { + merged.set(candidate.planId, candidate) + } + } + return Array.from(merged.values()) + }, [availablePlans, plan]) return ( - +
{planQuery.isError && (
@@ -38,37 +62,133 @@ export function Component() { {planQuery.isLoading || !plan ? ( ) : ( - 0 ? availablePlans : [plan]} /> + )}
) } -function PlanList({ currentPlanId, plans }: { currentPlanId: string; plans: BillingPlanSummary[] }) { +function PlanList({ + currentPlanId, + plans, + tenantId, + tenantSlug, +}: { + currentPlanId: string + plans: BillingPlanSummary[] + tenantId: string | null + tenantSlug: string | null +}) { return (
{plans.map((plan) => ( - + ))}
) } -function PlanCard({ plan, isCurrent }: { plan: BillingPlanSummary; isCurrent: boolean }) { +function PlanCard({ + plan, + isCurrent, + tenantId, + tenantSlug, +}: { + plan: BillingPlanSummary + isCurrent: boolean + tenantId: string | null + tenantSlug: string | null +}) { + const [checkoutLoading, setCheckoutLoading] = useState(false) + const [portalLoading, setPortalLoading] = useState(false) + const productId = plan.payment?.creemProductId ?? null + + const canCheckout = Boolean(!isCurrent && tenantId && productId) + + const showPortalButton = isCurrent && plan.planId !== 'free' && Boolean(productId) + + const handleCheckout = async () => { + if (!canCheckout || !tenantId || !productId) { + toast.error('该方案暂未开放,请稍后再试。') + return + } + setCheckoutLoading(true) + const successUrl = buildCheckoutSuccessUrl(tenantSlug) + const metadata: Record = { + tenantId, + planId: plan.planId, + } + if (tenantSlug) { + metadata.tenantSlug = tenantSlug + } + try { + const { data, error } = await authClient.creem.createCheckout({ + productId, + successUrl, + metadata, + }) + if (error) { + throw new Error(error.message ?? 'Creem 返回了未知错误') + } + if (data?.url) { + window.location.href = data.url + return + } + toast.error('Creem 未返回有效的结算链接,请稍后再试。') + } catch (error) { + toast.error(error instanceof Error ? error.message : '无法创建订阅结算会话') + } finally { + setCheckoutLoading(false) + } + } + + const handlePortal = async () => { + if (!showPortalButton) { + return + } + setPortalLoading(true) + try { + const { data, error } = await authClient.creem.createPortal() + if (error) { + throw new Error(error.message ?? '无法打开订阅管理') + } + if (data?.url) { + window.location.href = data.url + return + } + toast.error('Creem 未返回订阅管理地址,请稍后再试。') + } catch (error) { + toast.error(error instanceof Error ? error.message : '无法打开订阅管理') + } finally { + setPortalLoading(false) + } + } + return ( - +

{plan.name}

{plan.description}

+ {plan.pricing && plan.pricing.monthlyPrice !== null && plan.pricing.monthlyPrice !== undefined && ( +

+ {formatPrice(plan.pricing.monthlyPrice, plan.pricing.currency)} +

+ )}
- {isCurrent && } + {isCurrent && }
    @@ -81,16 +201,54 @@ function PlanCard({ plan, isCurrent }: { plan: BillingPlanSummary; isCurrent: bo
{!isCurrent && ( - )} -
+ + {showPortalButton && ( + + )} + ) } -function CurrentBadge() { - return 当前方案 +function buildCheckoutSuccessUrl(tenantSlug: string | null): string { + const { origin, pathname, search, hash, protocol, hostname, port } = window.location + const defaultUrl = `${origin}${pathname}${search}${hash}` + const isLocalSubdomain = hostname !== 'localhost' && hostname.endsWith('.localhost') + + if (!isLocalSubdomain) { + return defaultUrl + } + + const redirectOrigin = `${protocol}//localhost${port ? `:${port}` : ''}` + const redirectUrl = new URL('/creem-redirect.html', redirectOrigin) + redirectUrl.searchParams.set('redirect', defaultUrl) + if (tenantSlug) { + redirectUrl.searchParams.set('tenant', tenantSlug) + } + return redirectUrl.toString() +} + +function CurrentBadge({ planId }: { planId: string }) { + const label = planId === 'friend' ? '内部方案' : '当前方案' + return {label} } function renderQuotaValue(value: number | null, unit?: string): string { @@ -101,6 +259,12 @@ function renderQuotaValue(value: number | null, unit?: string): string { return unit ? `${numeral}${unit}` : numeral } +function formatPrice(value: number, currency: string | null | undefined): string { + const normalizedCurrency = currency?.toUpperCase() ?? '' + const formatted = value.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + return normalizedCurrency ? `${normalizedCurrency} ${formatted}` : formatted +} + function PlanSkeleton() { return (
diff --git a/be/apps/dashboard/src/pages/superadmin/builder.tsx b/be/apps/dashboard/src/pages/superadmin/builder.tsx index 5a6454cd..228d0415 100644 --- a/be/apps/dashboard/src/pages/superadmin/builder.tsx +++ b/be/apps/dashboard/src/pages/superadmin/builder.tsx @@ -1,22 +1,11 @@ -import { Spring } from '@afilmory/utils' -import { m } from 'motion/react' +import { MainPageLayout } from '~/components/layouts/MainPageLayout' import { BuilderSettingsForm } from '~/modules/builder-settings' export function Component() { return ( - -
-

构建器设置

-

调整照片构建任务的并发、日志输出与仓库同步策略。

-
- + -
+ ) } diff --git a/be/apps/dashboard/src/pages/superadmin/layout.tsx b/be/apps/dashboard/src/pages/superadmin/layout.tsx index fdadb4e6..6e66f0fe 100644 --- a/be/apps/dashboard/src/pages/superadmin/layout.tsx +++ b/be/apps/dashboard/src/pages/superadmin/layout.tsx @@ -9,10 +9,11 @@ export function Component() { const isSuperAdmin = useIsSuperAdmin() const navItems = [ { to: '/superadmin/settings', label: '系统设置', end: true }, + { to: '/superadmin/plans', label: '订阅计划', end: true }, { to: '/superadmin/tenants', label: '租户管理', end: true }, { label: '构建器', - to: '/settings/builder', + to: '/superadmin/builder', end: true, }, { to: '/superadmin/debug', label: 'Builder 调试', end: false }, diff --git a/be/apps/dashboard/src/pages/superadmin/plans.tsx b/be/apps/dashboard/src/pages/superadmin/plans.tsx new file mode 100644 index 00000000..e081f88c --- /dev/null +++ b/be/apps/dashboard/src/pages/superadmin/plans.tsx @@ -0,0 +1,26 @@ +import { Spring } from '@afilmory/utils' +import { m } from 'motion/react' + +import { SuperAdminSettingsForm } from '~/modules/super-admin' + +const PLAN_SECTION_IDS = ['billing-plan-settings'] as const + +export function Component() { + return ( + +
+

订阅计划配置

+

+ 管理各个订阅计划的资源配额、定价信息与 Creem Product 连接,仅超级管理员可编辑。 +

+
+ + +
+ ) +} diff --git a/be/apps/dashboard/src/pages/superadmin/settings.tsx b/be/apps/dashboard/src/pages/superadmin/settings.tsx index ea3fcea8..34f284e6 100644 --- a/be/apps/dashboard/src/pages/superadmin/settings.tsx +++ b/be/apps/dashboard/src/pages/superadmin/settings.tsx @@ -16,7 +16,7 @@ export function Component() {

管理整个平台的注册策略与本地登录渠道,由超级管理员统一维护。

- + ) } diff --git a/be/packages/db/migrations/0004_aromatic_bishop.sql b/be/packages/db/migrations/0004_aromatic_bishop.sql new file mode 100644 index 00000000..d621f6f3 --- /dev/null +++ b/be/packages/db/migrations/0004_aromatic_bishop.sql @@ -0,0 +1,16 @@ +CREATE TABLE "creem_subscription" ( + "id" text PRIMARY KEY NOT NULL, + "product_id" text NOT NULL, + "reference_id" text NOT NULL, + "creem_customer_id" text, + "creem_subscription_id" text, + "creem_order_id" text, + "status" text DEFAULT 'pending' NOT NULL, + "period_start" timestamp, + "period_end" timestamp, + "cancel_at_period_end" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "auth_user" ADD COLUMN "creem_customer_id" text; \ No newline at end of file diff --git a/be/packages/db/migrations/meta/0004_snapshot.json b/be/packages/db/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..5475e27f --- /dev/null +++ b/be/packages/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1485 @@ +{ + "id": "95dbb6b6-150d-43cb-81e4-4d31e2540f25", + "prevId": "c6dcc593-d20a-481d-ad9c-0bbba5229593", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_tenant_id_tenant_id_fk": { + "name": "auth_session_tenant_id_tenant_id_fk", + "tableFrom": "auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_customer_id": { + "name": "creem_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_user_tenant_id_tenant_id_fk": { + "name": "auth_user_tenant_id_tenant_id_fk", + "tableFrom": "auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_usage_event": { + "name": "billing_usage_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'count'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_billing_usage_event_tenant": { + "name": "idx_billing_usage_event_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_billing_usage_event_type": { + "name": "idx_billing_usage_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_usage_event_tenant_id_tenant_id_fk": { + "name": "billing_usage_event_tenant_id_tenant_id_fk", + "tableFrom": "billing_usage_event", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.creem_subscription": { + "name": "creem_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creem_customer_id": { + "name": "creem_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_subscription_id": { + "name": "creem_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_order_id": { + "name": "creem_order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_asset": { + "name": "photo_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_id": { + "name": "photo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_version": { + "name": "manifest_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v7'" + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "photo_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "conflict_reason": { + "name": "conflict_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conflict_payload": { + "name": "conflict_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "photo_asset_tenant_id_tenant_id_fk": { + "name": "photo_asset_tenant_id_tenant_id_fk", + "tableFrom": "photo_asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_photo_asset_tenant_storage_key": { + "name": "uq_photo_asset_tenant_storage_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "storage_key"] + }, + "uq_photo_asset_tenant_photo_id": { + "name": "uq_photo_asset_tenant_photo_id", + "nullsNotDistinct": false, + "columns": ["tenant_id", "photo_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_sync_run": { + "name": "photo_sync_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actions_count": { + "name": "actions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_photo_sync_run_tenant": { + "name": "idx_photo_sync_run_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "photo_sync_run_tenant_id_tenant_id_fk": { + "name": "photo_sync_run_tenant_id_tenant_id_fk", + "tableFrom": "photo_sync_run", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_key": { + "name": "ref_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_reactions_tenant_ref_key": { + "name": "idx_reactions_tenant_ref_key", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_tenant_id_tenant_id_fk": { + "name": "reactions_tenant_id_tenant_id_fk", + "tableFrom": "reactions", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_tenant_id_tenant_id_fk": { + "name": "settings_tenant_id_tenant_id_fk", + "tableFrom": "settings", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_settings_tenant_key": { + "name": "uq_settings_tenant_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_setting": { + "name": "system_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_system_setting_key": { + "name": "uq_system_setting_key", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_account": { + "name": "tenant_auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_account_tenant_id_tenant_id_fk": { + "name": "tenant_auth_account_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_account_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_account_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_session": { + "name": "tenant_auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_session_tenant_id_tenant_id_fk": { + "name": "tenant_auth_session_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_session_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_session_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_auth_session_token_unique": { + "name": "tenant_auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_user": { + "name": "tenant_auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'guest'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_user_tenant_id_tenant_id_fk": { + "name": "tenant_auth_user_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_auth_user_tenant_email": { + "name": "uq_tenant_auth_user_tenant_email", + "nullsNotDistinct": false, + "columns": ["tenant_id", "email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_slug": { + "name": "uq_tenant_slug", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.photo_sync_status": { + "name": "photo_sync_status", + "schema": "public", + "values": ["pending", "synced", "conflict"] + }, + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "inactive", "suspended"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["user", "admin", "superadmin"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/be/packages/db/migrations/meta/_journal.json b/be/packages/db/migrations/meta/_journal.json index 080fac5a..73957731 100644 --- a/be/packages/db/migrations/meta/_journal.json +++ b/be/packages/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1763292246073, "tag": "0003_natural_ultimates", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1763315183401, + "tag": "0004_aromatic_bishop", + "breakpoints": true } ] } diff --git a/be/packages/db/src/schema.ts b/be/packages/db/src/schema.ts index b79f97f5..0c9d6fc5 100644 --- a/be/packages/db/src/schema.ts +++ b/be/packages/db/src/schema.ts @@ -76,6 +76,7 @@ export const authUsers = pgTable('auth_user', { email: text('email').notNull().unique(), emailVerified: boolean('email_verified').default(false).notNull(), image: text('image'), + creemCustomerId: text('creem_customer_id'), role: userRoleEnum('role').notNull().default('user'), tenantId: text('tenant_id').references(() => tenants.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), @@ -131,6 +132,21 @@ export const authVerifications = pgTable('auth_verification', { updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(), }) +export const creemSubscriptions = pgTable('creem_subscription', { + id: text('id').primaryKey(), + productId: text('product_id').notNull(), + referenceId: text('reference_id').notNull(), + creemCustomerId: text('creem_customer_id'), + creemSubscriptionId: text('creem_subscription_id'), + creemOrderId: text('creem_order_id'), + status: text('status').notNull().default('pending'), + periodStart: timestamp('period_start', { mode: 'string' }), + periodEnd: timestamp('period_end', { mode: 'string' }), + cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(), +}) + export const tenantAuthUsers = pgTable( 'tenant_auth_user', { diff --git a/be/packages/env/src/index.ts b/be/packages/env/src/index.ts index 2b2e97db..f5e85416 100644 --- a/be/packages/env/src/index.ts +++ b/be/packages/env/src/index.ts @@ -23,6 +23,10 @@ export const env = createEnv({ CONFIG_ENCRYPTION_KEY: z.string().min(1), + // Payment + CREEM_API_KEY: z.string().min(1), + CREEM_WEBHOOK_SECRET: z.string().min(1), + DEFAULT_SUPERADMIN_EMAIL: z.email().default('root@local.host'), DEFAULT_SUPERADMIN_USERNAME: z .string() diff --git a/docs/billing-plans.md b/docs/billing-plans.md index d61700b5..97d6be28 100644 --- a/docs/billing-plans.md +++ b/docs/billing-plans.md @@ -22,6 +22,7 @@ This document tracks the current subscription plans, quota knobs, and the design 1. **Plan definitions** live in `billing-plan.constants.ts`. Each entry carries human-friendly metadata for the super-admin dashboard plus a `quotas` object. 2. **Overrides** are stored under `system.billing.planOverrides`. This is a JSON blob keyed by plan id. It is parsed through zod (`SystemSettingService.getBillingPlanOverrides`) and merged in the billing plan service. +3. **Payment product mapping** lives in `system.billing.planProducts`. Each plan id can map to provider specific identifiers (e.g. `creemProductId`). Plans that require checkout (like `pro`) stay hidden until a product id is configured, which prevents exposing upgrade buttons in environments that are not ready. 3. **Tenant assignment** is tracked via `tenant.plan_id` and can only be changed by superadmins (see `/super-admin/tenants` backend+dashboard). The Friend plan is intentionally absent from any public selector. 4. **Quota enforcement** is performed in: - `PhotoAssetService` (manual upload size + library limit + monthly process allowance) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74ee76f0..bad31505 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,7 +98,7 @@ importers: version: 9.39.1(jiti@2.6.1) eslint-config-hyoban: specifier: 4.0.10 - version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3) + version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3) fast-glob: specifier: 3.3.3 version: 3.3.3 @@ -345,7 +345,7 @@ importers: version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next: specifier: 16.0.1 - version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -578,7 +578,7 @@ importers: version: 0.31.6 next: specifier: 16.0.1 - version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) postcss: specifier: 8.5.6 version: 8.5.6 @@ -626,7 +626,7 @@ importers: version: 2.2.9(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@lobehub/fluent-emoji': specifier: 2.0.0 - version: 2.0.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 2.0.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@maplibre/maplibre-gl-geocoder': specifier: ^1.9.1 version: 1.9.1(maplibre-gl@5.12.0) @@ -689,7 +689,7 @@ importers: version: 10.2.0 jotai: specifier: 2.15.1 - version: 2.15.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0) + version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0) maplibre-gl: specifier: ^5.12.0 version: 5.12.0 @@ -737,7 +737,7 @@ importers: version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-scan: specifier: 0.4.3 - version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2) + version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2) react-use-measure: specifier: 2.1.7 version: 2.1.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -926,6 +926,9 @@ importers: '@aws-sdk/client-s3': specifier: 3.929.0 version: 3.929.0 + '@creem_io/better-auth': + specifier: 0.0.8 + version: 0.0.8(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12) '@hono/node-server': specifier: ^1.19.6 version: 1.19.6(hono@4.10.5) @@ -934,7 +937,7 @@ importers: version: 2.6.2 better-auth: specifier: 1.3.34 - version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@types/pg@8.15.6)(@vercel/postgres@0.10.0)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7) @@ -1008,6 +1011,9 @@ importers: '@afilmory/utils': specifier: workspace:* version: link:../../../packages/utils + '@creem_io/better-auth': + specifier: 0.0.8 + version: 0.0.8(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12) '@essentials/request-timeout': specifier: 1.3.0 version: 1.3.0 @@ -1061,7 +1067,7 @@ importers: version: 5.90.8(react@19.2.0) better-auth: specifier: 1.3.34 - version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -1079,7 +1085,7 @@ importers: version: 10.2.0 jotai: specifier: 2.15.1 - version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0) + version: 2.15.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.3)(react@19.2.0) lucide-react: specifier: 0.553.0 version: 0.553.0(react@19.2.0) @@ -1106,7 +1112,7 @@ importers: version: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-scan: specifier: 0.4.3 - version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2) + version: 0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2) sonner: specifier: 2.0.7 version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1164,7 +1170,7 @@ importers: version: 9.39.1(jiti@2.6.1) eslint-config-hyoban: specifier: 4.0.10 - version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3) + version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))(tailwindcss@4.1.17)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3) lint-staged: specifier: 16.2.6 version: 16.2.6 @@ -1548,7 +1554,7 @@ importers: version: 0.16.3(ms@2.1.3)(synckit@0.11.11)(typescript@5.9.3) unplugin-dts: specifier: 1.0.0-beta.6 - version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.49)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) vite: specifier: 7.2.2 version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) @@ -2408,6 +2414,13 @@ packages: conventional-commits-parser: optional: true + '@creem_io/better-auth@0.0.8': + resolution: {integrity: sha512-tTDsYqbyRgryk5jSBg+i8/eI05JLRLNOEF5aYsnKVDrEHerVMHz6Lp6059zewQ/bhcwKdn9Bwm5F2iFoL426tw==} + peerDependencies: + better-auth: ^1.3.34 + creem: ^0.4.0 + zod: ^3.23.8 || ^4 + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -7004,6 +7017,15 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + creem@0.4.0: + resolution: {integrity: sha512-1RENze+vqUSq478DzisYaFuHmHt24QKg2zASuThQ9GqXi1XnW1KQa6ekuO0rA2RGqiDSYyfR6+DxaGJBYuVghg==} + hasBin: true + peerDependencies: + '@modelcontextprotocol/sdk': '>=1.5.0 <1.10.0' + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -12896,9 +12918,9 @@ snapshots: regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.4(@babel/core@7.28.4)': + '@babel/helper-define-polyfill-provider@0.6.4(@babel/core@7.28.5)': dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.28.5 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 debug: 4.4.3(supports-color@5.5.0) @@ -13363,14 +13385,14 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-runtime@7.27.4(@babel/core@7.28.4)': + '@babel/plugin-transform-runtime@7.27.4(@babel/core@7.28.5)': dependencies: - '@babel/core': 7.28.4 + '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 - babel-plugin-polyfill-corejs2: 0.4.13(@babel/core@7.28.4) - babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.28.4) - babel-plugin-polyfill-regenerator: 0.6.4(@babel/core@7.28.4) + babel-plugin-polyfill-corejs2: 0.4.13(@babel/core@7.28.5) + babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.28.5) + babel-plugin-polyfill-regenerator: 0.6.4(@babel/core@7.28.5) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -13663,6 +13685,12 @@ snapshots: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.2.1 + '@creem_io/better-auth@0.0.8(better-auth@1.3.34)(creem@0.4.0)(zod@4.1.12)': + dependencies: + better-auth: 1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + creem: 0.4.0 + zod: 4.1.12 + '@discoveryjs/json-ext@0.5.7': {} '@dnd-kit/accessibility@3.1.1(react@19.2.0)': @@ -14608,10 +14636,10 @@ snapshots: '@lobehub/emojilib@1.0.0': {} - '@lobehub/fluent-emoji@2.0.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@lobehub/fluent-emoji@2.0.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@lobehub/emojilib': 1.0.0 - '@lobehub/ui': 2.7.3(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@lobehub/ui': 2.7.3(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd: 5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd-style: 3.7.1(@types/react@19.2.3)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) emoji-regex: 10.4.0 @@ -14628,9 +14656,9 @@ snapshots: - framer-motion - supports-color - '@lobehub/icons@2.7.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@lobehub/icons@2.7.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@lobehub/ui': 2.7.3(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@lobehub/ui': 2.7.3(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd: 5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) antd-style: 3.7.1(@types/react@19.2.3)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) lucide-react: 0.469.0(react@19.2.0) @@ -14645,7 +14673,7 @@ snapshots: - framer-motion - supports-color - '@lobehub/ui@2.7.3(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@lobehub/ui@2.7.3(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@ant-design/cssinjs': 1.23.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -14656,8 +14684,8 @@ snapshots: '@emoji-mart/react': 1.1.1(emoji-mart@5.6.0)(react@19.2.0) '@floating-ui/react': 0.27.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@giscus/react': 3.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@lobehub/fluent-emoji': 2.0.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@lobehub/icons': 2.7.0(@babel/core@7.28.4)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@lobehub/fluent-emoji': 2.0.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@lobehub/icons': 2.7.0(@babel/core@7.28.5)(@types/react@19.2.3)(acorn@8.15.0)(antd@5.26.2(luxon@3.7.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(framer-motion@12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mdx-js/mdx': 3.1.0(acorn@8.15.0) '@mdx-js/react': 3.1.1(@types/react@19.2.3)(react@19.2.0) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.3)(react@19.2.0) @@ -14687,7 +14715,7 @@ snapshots: rc-menu: 9.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) re-resizable: 6.11.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 - react-avatar-editor: 13.0.2(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-avatar-editor: 13.0.2(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-dom: 19.2.0(react@19.2.0) react-error-boundary: 5.0.0(react@19.2.0) react-hotkeys-hook: 5.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -18158,11 +18186,11 @@ snapshots: cosmiconfig: 7.1.0 resolve: 1.22.11 - babel-plugin-polyfill-corejs2@0.4.13(@babel/core@7.28.4): + babel-plugin-polyfill-corejs2@0.4.13(@babel/core@7.28.5): dependencies: '@babel/compat-data': 7.28.0 - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.4) + '@babel/core': 7.28.5 + '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.5) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -18176,10 +18204,10 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.11.1(@babel/core@7.28.4): + babel-plugin-polyfill-corejs3@0.11.1(@babel/core@7.28.5): dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.4) + '@babel/core': 7.28.5 + '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.5) core-js-compat: 3.46.0 transitivePeerDependencies: - supports-color @@ -18192,10 +18220,10 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.4(@babel/core@7.28.4): + babel-plugin-polyfill-regenerator@0.6.4(@babel/core@7.28.5): dependencies: - '@babel/core': 7.28.4 - '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.4) + '@babel/core': 7.28.5 + '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.28.5) transitivePeerDependencies: - supports-color @@ -18220,7 +18248,7 @@ snapshots: batch-cluster@15.0.1: {} - better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + better-auth@1.3.34(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) @@ -18237,7 +18265,7 @@ snapshots: nanostores: 1.0.1 zod: 4.1.12 optionalDependencies: - next: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -18657,6 +18685,10 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + creem@0.4.0: + dependencies: + zod: 3.25.76 + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -21839,7 +21871,7 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.1 '@swc/helpers': 0.5.15 @@ -21857,14 +21889,13 @@ snapshots: '@next/swc-linux-x64-musl': 16.0.1 '@next/swc-win32-arm64-msvc': 16.0.1 '@next/swc-win32-x64-msvc': 16.0.1 - babel-plugin-react-compiler: 19.1.0-rc.3 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros optional: true - next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.1 '@swc/helpers': 0.5.15 @@ -21882,6 +21913,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.0.1 '@next/swc-win32-arm64-msvc': 16.0.1 '@next/swc-win32-x64-msvc': 16.0.1 + babel-plugin-react-compiler: 19.1.0-rc.3 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -22803,9 +22835,9 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - react-avatar-editor@13.0.2(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + react-avatar-editor@13.0.2(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@babel/plugin-transform-runtime': 7.27.4(@babel/core@7.28.4) + '@babel/plugin-transform-runtime': 7.27.4(@babel/core@7.28.5) '@babel/runtime': 7.28.4 prop-types: 15.8.1 react: 19.2.0 @@ -23004,38 +23036,7 @@ snapshots: optionalDependencies: react-dom: 19.2.0(react@19.2.0) - react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2): - dependencies: - '@babel/core': 7.28.4 - '@babel/generator': 7.28.3 - '@babel/types': 7.28.4 - '@clack/core': 0.3.5 - '@clack/prompts': 0.8.2 - '@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@preact/signals': 1.3.2(preact@10.27.2) - '@rollup/pluginutils': 5.3.0(rollup@2.79.2) - '@types/node': 20.19.25 - bippy: 0.3.27(@types/react@19.2.3)(react@19.2.0) - esbuild: 0.25.11 - estree-walker: 3.0.3 - kleur: 4.1.5 - mri: 1.2.0 - playwright: 1.55.0 - preact: 10.27.2 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - tsx: 4.20.6 - optionalDependencies: - next: 16.0.1(@babel/core@7.28.4)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - unplugin: 2.1.0 - transitivePeerDependencies: - - '@types/react' - - rollup - - supports-color - - react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2): + react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.53.2): dependencies: '@babel/core': 7.28.4 '@babel/generator': 7.28.3 @@ -23057,7 +23058,38 @@ snapshots: react-dom: 19.2.0(react@19.2.0) tsx: 4.20.6 optionalDependencies: - next: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + unplugin: 2.1.0 + transitivePeerDependencies: + - '@types/react' + - rollup + - supports-color + + react-scan@0.4.3(@types/react@19.2.3)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2): + dependencies: + '@babel/core': 7.28.4 + '@babel/generator': 7.28.3 + '@babel/types': 7.28.4 + '@clack/core': 0.3.5 + '@clack/prompts': 0.8.2 + '@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@preact/signals': 1.3.2(preact@10.27.2) + '@rollup/pluginutils': 5.3.0(rollup@2.79.2) + '@types/node': 20.19.25 + bippy: 0.3.27(@types/react@19.2.3)(react@19.2.0) + esbuild: 0.25.11 + estree-walker: 3.0.3 + kleur: 4.1.5 + mri: 1.2.0 + playwright: 1.55.0 + preact: 10.27.2 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tsx: 4.20.6 + optionalDependencies: + next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router: 7.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) unplugin: 2.1.0 @@ -24380,7 +24412,7 @@ snapshots: magic-string-ast: 1.0.3 unplugin: 2.3.10 - unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): + unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.49)(rollup@4.53.2)(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.53.2) '@volar/typescript': 2.4.23 @@ -24394,6 +24426,7 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.52.13(@types/node@24.10.1) esbuild: 0.25.12 + rolldown: 1.0.0-beta.49 rollup: 4.53.2 vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: