features.ts 10 KB

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