supermemory/apps/web/lib/billing-utils.ts
MaheshtheDev 9bbe436fab docs(brain): note trial attach after Slack OAuth (#1313)
## Summary
- Documents that mono attaches Scale 14d trial + Company Brain after Slack OAuth from `/brain`.

Stacks on #1310. Billing implementation is in mono.
2026-07-21 21:34:25 +00:00

226 lines
6.4 KiB
TypeScript

const COMPANY_BRAIN_PRODUCT_ID = "company_brain"
// Add-on resolved by product presence, not tier.
// better-auth returns org.metadata as a JSON string, so accept string or object.
export function hasCompanyBrain(
metadataRaw: Record<string, unknown> | string | null | undefined,
): boolean {
if (!metadataRaw) return false
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return false
}
} else {
metadata = metadataRaw
}
const overrides = metadata.featureOverrides as
| Record<string, { allow?: boolean }>
| undefined
const override = overrides?.[COMPANY_BRAIN_PRODUCT_ID]
if (override) return Boolean(override.allow)
const activeProducts = Array.isArray(metadata.activeProducts)
? (metadata.activeProducts as string[])
: []
return activeProducts.includes(COMPANY_BRAIN_PRODUCT_ID)
}
// Explicit concierge override for company_brain, or undefined when none is set.
export function getCompanyBrainOverride(
metadataRaw: Record<string, unknown> | string | null | undefined,
): boolean | undefined {
if (!metadataRaw) return undefined
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return undefined
}
} else {
metadata = metadataRaw
}
const overrides = metadata.featureOverrides as
| Record<string, { allow?: boolean }>
| undefined
const override = overrides?.[COMPANY_BRAIN_PRODUCT_ID]
return override ? Boolean(override.allow) : undefined
}
// Origin of the org. Consumer (app.supermemory) orgs get company_brain attached,
// but the add-on lands async — signupSource is set at creation, so it's the
// reliable "this org uses brain spaces" signal in the UI.
export function getSignupSource(
metadataRaw: Record<string, unknown> | string | null | undefined,
): string | null {
if (!metadataRaw) return null
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
} else {
metadata = metadataRaw
}
return typeof metadata.signupSource === "string"
? (metadata.signupSource as string)
: null
}
// Company domain captured during team onboarding.
export function getBrainWorkspaceDomain(
metadataRaw: Record<string, unknown> | string | null | undefined,
): string | null {
if (!metadataRaw) return null
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
} else {
metadata = metadataRaw
}
return typeof metadata.brainWorkspaceDomain === "string"
? (metadata.brainWorkspaceDomain as string)
: null
}
// Brain mode chosen during onboarding ("personal" | "team"). Set synchronously
// at org creation, so it's the reliable pre-webhook signal for company brain.
export function getBrainMode(
metadataRaw: Record<string, unknown> | string | null | undefined,
): string | null {
if (!metadataRaw) return null
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
} else {
metadata = metadataRaw
}
return typeof metadata.brainMode === "string"
? (metadata.brainMode as string)
: null
}
export type BrainTrialStatus =
| "active"
| "exhausted"
| "expired"
| "converted"
| "skipped"
export type BrainTrialInfo = {
status: BrainTrialStatus | null
startedAtMs: number | null
endsAtMs: number | null
credits: number | null
daysRemaining: number | null
}
function parseOrgMetadata(
metadataRaw: Record<string, unknown> | string | null | undefined,
): Record<string, unknown> | null {
if (!metadataRaw) return null
if (typeof metadataRaw === "string") {
try {
return JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
}
return metadataRaw
}
/** Company Brain Slack trial fields written by mono after OAuth attach. */
export function getBrainTrialInfo(
metadataRaw: Record<string, unknown> | string | null | undefined,
): BrainTrialInfo {
const metadata = parseOrgMetadata(metadataRaw)
const rawStatus = metadata?.brainTrialStatus
const status =
rawStatus === "active" ||
rawStatus === "exhausted" ||
rawStatus === "expired" ||
rawStatus === "converted" ||
rawStatus === "skipped"
? rawStatus
: null
const startedAtMs =
typeof metadata?.brainTrialStartedAt === "string"
? Date.parse(metadata.brainTrialStartedAt)
: Number.NaN
const endsAtMs =
typeof metadata?.brainTrialEndsAt === "string"
? Date.parse(metadata.brainTrialEndsAt)
: Number.NaN
const credits =
typeof metadata?.brainTrialCredits === "number"
? metadata.brainTrialCredits
: null
const safeEnds = Number.isFinite(endsAtMs) ? endsAtMs : null
const daysRemaining =
safeEnds != null
? Math.max(0, Math.ceil((safeEnds - Date.now()) / (1000 * 60 * 60 * 24)))
: null
return {
status,
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null,
endsAtMs: safeEnds,
credits,
daysRemaining,
}
}
/**
* Format a number with K/M suffix for display
* @example formatUsageNumber(1500000) => "1.5M"
* @example formatUsageNumber(50000) => "50K"
*/
export function formatUsageNumber(value: number): string {
if (value >= 1_000_000) {
const millions = value / 1_000_000
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`
}
if (value >= 1_000) {
const thousands = value / 1_000
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`
}
return value.toString()
}
/**
* Calculate usage percentage, clamped between 0 and 100
*/
export function calculateUsagePercent(used: number, limit: number): number {
if (limit <= 0) return 0
return Math.min(Math.max((used / limit) * 100, 0), 100)
}
/**
* Get the number of days remaining until a reset date
* @param resetAt - Unix timestamp in milliseconds (from Autumn billing)
* @returns number of days, or null if no date provided
*/
export function getDaysRemaining(
resetAt: number | null | undefined,
): number | null {
if (!resetAt) return null
const end = new Date(resetAt)
const now = new Date()
const diffMs = end.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
return Math.max(0, diffDays)
}