features.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import { cache } from 'react'
  2. import { isCloudInstance } from './cloud-instance'
  3. import { db } from './db'
  4. import { verifyLicenseToken } from './license/token'
  5. import { scheduleLicenseSelfHeal } from './license/revalidate'
  6. export type Plan = 'free' | 'pro' | 'enterprise' | 'white-label'
  7. export type PlanFeatures = {
  8. maxOrganizations: number
  9. maxCustomers: number
  10. maxUsers: number
  11. templates: number
  12. customTemplates: boolean
  13. reports: boolean
  14. smtp: boolean
  15. api: boolean
  16. integrations: boolean
  17. payments: boolean
  18. customFields: boolean
  19. sms: boolean
  20. telegram: boolean
  21. whatsapp: boolean
  22. brandingRemoved: boolean
  23. customPlatformName: boolean
  24. maxImagesPerService: number
  25. maxDiagnosticsPerService: number
  26. maxDocumentsPerService: number
  27. customerPortal: boolean
  28. ai: boolean
  29. tireHotel: boolean
  30. }
  31. export const PLAN_FEATURES: Record<Plan, PlanFeatures> = {
  32. free: {
  33. maxOrganizations: 1,
  34. // Enough to run real work for a few weeks before the plan is felt. Five
  35. // was reached in the first afternoon, often on the third customer once
  36. // the seeded samples were counted, and people left instead of upgrading.
  37. maxCustomers: 20,
  38. maxUsers: 1,
  39. templates: 2,
  40. customTemplates: false,
  41. reports: true,
  42. smtp: false,
  43. api: false,
  44. integrations: false,
  45. payments: false,
  46. customFields: false,
  47. sms: false,
  48. telegram: false,
  49. whatsapp: false,
  50. brandingRemoved: false,
  51. customPlatformName: false,
  52. maxImagesPerService: 5,
  53. maxDiagnosticsPerService: 5,
  54. maxDocumentsPerService: 5,
  55. customerPortal: false,
  56. ai: true,
  57. tireHotel: false,
  58. },
  59. pro: {
  60. maxOrganizations: 3,
  61. maxCustomers: 999999,
  62. maxUsers: 5,
  63. templates: 999999,
  64. customTemplates: true,
  65. reports: true,
  66. smtp: true,
  67. api: true,
  68. integrations: true,
  69. payments: true,
  70. customFields: true,
  71. sms: true,
  72. telegram: true,
  73. whatsapp: true,
  74. brandingRemoved: true,
  75. customPlatformName: true,
  76. maxImagesPerService: 30,
  77. maxDiagnosticsPerService: 30,
  78. maxDocumentsPerService: 30,
  79. customerPortal: true,
  80. ai: true,
  81. tireHotel: true,
  82. },
  83. enterprise: {
  84. maxOrganizations: 10,
  85. maxCustomers: 999999,
  86. maxUsers: 50,
  87. templates: 999999,
  88. customTemplates: true,
  89. reports: true,
  90. smtp: true,
  91. api: true,
  92. integrations: true,
  93. payments: true,
  94. customFields: true,
  95. sms: true,
  96. telegram: true,
  97. whatsapp: true,
  98. brandingRemoved: true,
  99. customPlatformName: true,
  100. maxImagesPerService: 100,
  101. maxDiagnosticsPerService: 100,
  102. maxDocumentsPerService: 100,
  103. customerPortal: true,
  104. ai: true,
  105. tireHotel: true,
  106. },
  107. 'white-label': {
  108. maxOrganizations: 999999,
  109. maxCustomers: 999999,
  110. maxUsers: 999999,
  111. templates: 999999,
  112. customTemplates: true,
  113. reports: true,
  114. smtp: true,
  115. api: true,
  116. integrations: true,
  117. payments: true,
  118. customFields: true,
  119. sms: true,
  120. telegram: true,
  121. whatsapp: true,
  122. brandingRemoved: true,
  123. customPlatformName: true,
  124. maxImagesPerService: 999999,
  125. maxDiagnosticsPerService: 999999,
  126. maxDocumentsPerService: 999999,
  127. customerPortal: true,
  128. ai: true,
  129. tireHotel: true,
  130. },
  131. }
  132. /** TORQVOICE_MODE=cloud with a token torqvoice.com signed for this URL. See lib/cloud-instance. */
  133. export function isCloudMode(): boolean {
  134. return isCloudInstance()
  135. }
  136. /**
  137. * Torqvoice branding on invoices, quotes, inspections and share pages is a
  138. * self-hosted matter: the free install carries the mark, the white-label
  139. * licence removes it. On the cloud instance nobody gets the mark, whatever
  140. * the plan. A new workshop downloading its first invoice from our own
  141. * service should see its own name on it, not ours all over it.
  142. */
  143. function cloudPlan(plan: Plan): PlanFeatures {
  144. return { ...PLAN_FEATURES[plan], brandingRemoved: true }
  145. }
  146. // Grace period (in ms) after currentPeriodEnd before we cut off features.
  147. // Gives Stripe time to process renewals and deliver webhooks, and the daily
  148. // cron time to sync. 3 days covers Stripe's initial retry window.
  149. const SUBSCRIPTION_GRACE_MS = 3 * 24 * 60 * 60 * 1000
  150. export const getFeatures = cache(async (organizationId: string): Promise<PlanFeatures> => {
  151. if (isCloudMode()) {
  152. const subscription = await db.subscription.findUnique({
  153. where: { organizationId },
  154. include: { plan: true },
  155. })
  156. if (!subscription) {
  157. return cloudPlan('free')
  158. }
  159. // Only active and trialing subscriptions grant premium features
  160. if (subscription.status !== 'active' && subscription.status !== 'trialing') {
  161. return cloudPlan('free')
  162. }
  163. // Defense-in-depth: if the billing period has ended and grace has elapsed,
  164. // treat as expired even if status hasn't been updated yet (missed webhook).
  165. if (subscription.currentPeriodEnd) {
  166. const graceDeadline = new Date(
  167. subscription.currentPeriodEnd.getTime() + SUBSCRIPTION_GRACE_MS
  168. )
  169. if (new Date() > graceDeadline) {
  170. return cloudPlan('free')
  171. }
  172. }
  173. const name = subscription.plan.name.toLowerCase()
  174. const planName: Plan = name.includes('enterprise')
  175. ? 'enterprise'
  176. : name.includes('pro')
  177. ? 'pro'
  178. : 'free'
  179. return cloudPlan(planName)
  180. }
  181. // Self-hosted mode — all features unlocked, license only controls branding.
  182. //
  183. // The gate trusts one thing: a token signed by torqvoice.com, bound to this
  184. // organization, refreshed within the last two weeks. The operator owns this
  185. // database, so `license.valid` and friends are display cache only; editing
  186. // them changes nothing here. See src/lib/license/token.ts.
  187. const settings = await db.appSetting.findMany({
  188. where: {
  189. organizationId,
  190. key: { in: ['license.token', 'license.key'] },
  191. },
  192. })
  193. const map = new Map(settings.map((s) => [s.key, s.value]))
  194. const verification = verifyLicenseToken(map.get('license.token'), organizationId)
  195. const hasLicense = verification.status === 'valid'
  196. // A key with no usable token is an install that has not talked to
  197. // torqvoice.com recently, or one that upgraded from the release that stored
  198. // plain booleans. Refresh in the background; the cron would get there within
  199. // a day anyway, this just makes the upgrade invisible.
  200. const key = map.get('license.key')
  201. if (key && !hasLicense) {
  202. scheduleLicenseSelfHeal(organizationId, key)
  203. }
  204. return {
  205. ...PLAN_FEATURES['white-label'],
  206. brandingRemoved: hasLicense,
  207. customPlatformName: hasLicense,
  208. // One workshop per install without a licence. A self-hosting workshop
  209. // needs one; running many is what the white-label licence is for.
  210. maxOrganizations: hasLicense ? PLAN_FEATURES['white-label'].maxOrganizations : 1,
  211. }
  212. })
  213. /**
  214. * Whether any organization on this install holds a valid licence token.
  215. * The licence is what turns a single-workshop install into a multi-workshop
  216. * one, so it is checked across the install, not per person.
  217. */
  218. export async function installHasLicense(): Promise<boolean> {
  219. const tokens = await db.appSetting.findMany({
  220. where: { key: 'license.token', organizationId: { not: null } },
  221. select: { organizationId: true, value: true },
  222. })
  223. return tokens.some(
  224. (row) =>
  225. row.organizationId !== null &&
  226. verifyLicenseToken(row.value, row.organizationId).status === 'valid'
  227. )
  228. }
  229. /**
  230. * Returns the max organizations a user is allowed based on their best plan
  231. * across all orgs they own. In self-hosted mode the limit is one for the
  232. * whole install, lifted by a valid white-label licence.
  233. */
  234. export async function getMaxOrganizations(userId: string): Promise<number> {
  235. if (!isCloudMode()) {
  236. return (await installHasLicense()) ? PLAN_FEATURES['white-label'].maxOrganizations : 1
  237. }
  238. const ownedOrgs = await db.organizationMember.findMany({
  239. where: { userId, role: 'owner' },
  240. select: { organizationId: true },
  241. })
  242. let best = PLAN_FEATURES.free.maxOrganizations
  243. for (const membership of ownedOrgs) {
  244. const features = await getFeatures(membership.organizationId)
  245. if (features.maxOrganizations > best) {
  246. best = features.maxOrganizations
  247. }
  248. }
  249. return best
  250. }
  251. export type OrganizationAllowance = {
  252. allowed: boolean
  253. /** what counts against the limit: organizations owned (cloud) or on the install (self-hosted) */
  254. current: number
  255. max: number
  256. }
  257. /**
  258. * Whether one more organization may be created. On the cloud the limit is
  259. * the plan's and counts what this person owns; on a self-hosted install it
  260. * counts every organization there is, so a second sign-up cannot open a
  261. * second workshop on an install licensed for one.
  262. */
  263. export async function organizationAllowance(userId: string): Promise<OrganizationAllowance> {
  264. const max = await getMaxOrganizations(userId)
  265. const current = isCloudMode()
  266. ? await db.organizationMember.count({ where: { userId, role: 'owner' } })
  267. : await db.organization.count()
  268. return { allowed: current < max, current, max }
  269. }
  270. /** The refusal an install licensed for one workshop gives, worded for a self-hoster. */
  271. export const SINGLE_WORKSHOP_MESSAGE =
  272. 'This installation runs one workshop. A white-label licence from torqvoice.com allows more; see Settings, then License.'
  273. /**
  274. * Thrown when the plan refuses an action. `withAuth` turns it into a typed
  275. * `gated` field on the result, so the client can show an upgrade prompt with
  276. * the actual number instead of a red error box. The message is only a
  277. * fallback for callers that do not look at `gated`.
  278. */
  279. export class FeatureGatedError extends Error {
  280. feature: string
  281. limit?: number
  282. constructor(feature: string, message?: string, limit?: number) {
  283. super(message ?? `This feature requires an upgraded plan: ${feature}`)
  284. this.name = 'FeatureGatedError'
  285. this.feature = feature
  286. this.limit = limit
  287. }
  288. }
  289. export async function requireFeature(
  290. organizationId: string,
  291. feature: keyof PlanFeatures
  292. ): Promise<void> {
  293. const features = await getFeatures(organizationId)
  294. if (!features[feature]) {
  295. throw new FeatureGatedError(feature)
  296. }
  297. }