create-crm-starter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/README.md +53 -0
  2. package/dist/index.js +2341 -0
  3. package/package.json +51 -0
  4. package/template/base/_dot_gitignore +11 -0
  5. package/template/base/eslint.config.mjs +7 -0
  6. package/template/base/next.config.ts +17 -0
  7. package/template/base/package.json +35 -0
  8. package/template/base/postcss.config.mjs +7 -0
  9. package/template/base/scripts/setup.mjs +146 -0
  10. package/template/base/src/app/globals.css +68 -0
  11. package/template/base/src/app/layout.tsx +19 -0
  12. package/template/base/src/app/page.tsx +37 -0
  13. package/template/base/src/components/ui/badge.tsx +29 -0
  14. package/template/base/src/components/ui/button.tsx +49 -0
  15. package/template/base/src/components/ui/card.tsx +54 -0
  16. package/template/base/src/components/ui/input.tsx +19 -0
  17. package/template/base/src/components/ui/label.tsx +19 -0
  18. package/template/base/src/components/ui/separator.tsx +25 -0
  19. package/template/base/src/components/ui/skeleton.tsx +7 -0
  20. package/template/base/src/lib/utils.ts +17 -0
  21. package/template/base/tsconfig.json +21 -0
  22. package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
  23. package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
  24. package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
  25. package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
  26. package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
  27. package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
  28. package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
  29. package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
  30. package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
  31. package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
  32. package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
  33. package/template/extras/auth-better-auth/src/middleware.ts +25 -0
  34. package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
  35. package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
  36. package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
  37. package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
  38. package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
  39. package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
  40. package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
  41. package/template/extras/auth-clerk/src/middleware.ts +17 -0
  42. package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
  43. package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
  44. package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
  45. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
  46. package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
  47. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
  48. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
  49. package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
  50. package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
  51. package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
  52. package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
  53. package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
  54. package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
  55. package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
  56. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
  57. package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
  58. package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
  59. package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
  60. package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
  61. package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
  62. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
  63. package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
  64. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
  65. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
  66. package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
  67. package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
  68. package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
  69. package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
  70. package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
  71. package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
  72. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
  73. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
  74. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
  75. package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
  76. package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
  77. package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
  78. package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
  79. package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
  80. package/template/extras/customers/_fragments/convex.txt +28 -0
  81. package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
  82. package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
  83. package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
  84. package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
  85. package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
  86. package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
  87. package/template/extras/customers/convex/convex/customers.ts +52 -0
  88. package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
  89. package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
  90. package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
  91. package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
  92. package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
  93. package/template/extras/db-convex/convex/_generated/README.md +13 -0
  94. package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
  95. package/template/extras/db-convex/convex/_generated/api.js +12 -0
  96. package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
  97. package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
  98. package/template/extras/db-convex/convex/_generated/server.js +12 -0
  99. package/template/extras/db-convex/convex/auth.config.ts +17 -0
  100. package/template/extras/db-convex/convex/schema.ts +28 -0
  101. package/template/extras/db-convex/src/app/layout.tsx +20 -0
  102. package/template/extras/db-convex/src/components/providers.tsx +28 -0
  103. package/template/extras/db-convex/src/lib/convex.ts +6 -0
  104. package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
  105. package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
  106. package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
  107. package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
  108. package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
  109. package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
  110. package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
  111. package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
  112. package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
  113. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
  114. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
  115. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
  116. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
  117. package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
  118. package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
  119. package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
  120. package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
  121. package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
  122. package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
  123. package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
  124. package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
  125. package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
  126. package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
  127. package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
  128. package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
  129. package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
  130. package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
  131. package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
  132. package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
  133. package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
  134. package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
  135. package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
  136. package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
  137. package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
  138. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
  139. package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
  140. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
  141. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
  142. package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
  143. package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
  144. package/template/extras/jobs/_fragments/convex.txt +21 -0
  145. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
  146. package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
  147. package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
  148. package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
  149. package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
  150. package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
  151. package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
  152. package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
  153. package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
  154. package/template/extras/jobs/convex/convex/jobs.ts +46 -0
  155. package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
  156. package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
  157. package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
  158. package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
  159. package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
  160. package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
  161. package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
  162. package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
  163. package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
  164. package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
  165. package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
  166. package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
  167. package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
  168. package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
  169. package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
  170. package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
  171. package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
  172. package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
  173. package/template/extras/mobile/mobile/README.md +67 -0
  174. package/template/extras/mobile/mobile/_dot_env.example +5 -0
  175. package/template/extras/mobile/mobile/_dot_gitignore +26 -0
  176. package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
  177. package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
  178. package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
  179. package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
  180. package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
  181. package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
  182. package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
  183. package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
  184. package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
  185. package/template/extras/mobile/mobile/app/index.tsx +23 -0
  186. package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
  187. package/template/extras/mobile/mobile/app.brand.ts +14 -0
  188. package/template/extras/mobile/mobile/app.config.ts +40 -0
  189. package/template/extras/mobile/mobile/app.features.ts +11 -0
  190. package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
  191. package/template/extras/mobile/mobile/eas.json +22 -0
  192. package/template/extras/mobile/mobile/lib/api.ts +253 -0
  193. package/template/extras/mobile/mobile/lib/auth.ts +51 -0
  194. package/template/extras/mobile/mobile/lib/format.ts +23 -0
  195. package/template/extras/mobile/mobile/lib/push.ts +24 -0
  196. package/template/extras/mobile/mobile/lib/theme.ts +16 -0
  197. package/template/extras/mobile/mobile/package.json +34 -0
  198. package/template/extras/mobile/mobile/tsconfig.json +11 -0
  199. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
  200. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
  201. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
  202. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
  203. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
  204. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
  205. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
  206. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
  207. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
  208. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
  209. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
  210. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
  211. package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
  212. package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
  213. package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
  214. package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
  215. package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
  216. package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
  217. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
  218. package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
  219. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
  220. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
  221. package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
  222. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
  223. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
  224. package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
  225. package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
  226. package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
  227. package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
  228. package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
  229. package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
  230. package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
  231. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
  232. package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
  233. package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
  234. package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
  235. package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
  236. package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
  237. package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
  238. package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
  239. package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
  240. package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
  241. package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
  242. package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
  243. package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
  244. package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
  245. package/template/extras/seed/_shared/scripts/seed.ts +35 -0
  246. package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
  247. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
  248. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
  249. package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
  250. package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
  251. package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
  252. package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
  253. package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
  254. package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
  255. package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
  256. package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
  257. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
  258. package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
  259. package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
  260. package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
  261. package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
@@ -0,0 +1,57 @@
1
+ export const BILLING_INTERVALS = ['monthly', 'quarterly', 'annual'] as const;
2
+ export type BillingInterval = (typeof BILLING_INTERVALS)[number];
3
+
4
+ export const BILLING_INTERVAL_LABEL: Record<BillingInterval, string> = {
5
+ monthly: 'Monthly',
6
+ quarterly: 'Quarterly',
7
+ annual: 'Annual',
8
+ };
9
+
10
+ export const SUBSCRIPTION_STATUSES = ['active', 'paused', 'cancelled'] as const;
11
+ export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUSES)[number];
12
+
13
+ export const SUBSCRIPTION_STATUS_LABEL: Record<SubscriptionStatus, string> = {
14
+ active: 'Active',
15
+ paused: 'Paused',
16
+ cancelled: 'Cancelled',
17
+ };
18
+
19
+ export const SUBSCRIPTION_STATUS_VARIANT: Record<
20
+ SubscriptionStatus,
21
+ 'default' | 'secondary' | 'destructive' | 'outline'
22
+ > = {
23
+ active: 'default',
24
+ paused: 'secondary',
25
+ cancelled: 'destructive',
26
+ };
27
+
28
+ /**
29
+ * A plan template (silver/gold/platinum etc.) — defined once per business
30
+ * and assigned to customers as subscriptions.
31
+ */
32
+ export interface ServicePlan {
33
+ id: string;
34
+ name: string;
35
+ description?: string;
36
+ /** Price billed per `billingInterval`, in cents. */
37
+ price: number;
38
+ billingInterval: BillingInterval;
39
+ /** How many service visits the customer receives per year. */
40
+ visitsPerYear: number;
41
+ perks?: string;
42
+ /** Computed: estimated annual revenue per subscriber based on interval. */
43
+ arrPerSubscriber: number;
44
+ activeSubscribers: number;
45
+ monthlyRecurringRevenue: number;
46
+ }
47
+
48
+ export interface ServicePlanSubscription {
49
+ id: string;
50
+ customerId: string;
51
+ customerName: string;
52
+ planId: string;
53
+ planName: string;
54
+ status: SubscriptionStatus;
55
+ startedAt: string;
56
+ renewsAt?: string;
57
+ }
@@ -0,0 +1,10 @@
1
+ import { NewServicePlanForm } from '@/components/service-plans/new-plan-form';
2
+
3
+ export default function NewServicePlanPage() {
4
+ return (
5
+ <div className="mx-auto max-w-2xl space-y-6">
6
+ <h1 className="text-3xl font-bold tracking-tight">New service plan</h1>
7
+ <NewServicePlanForm />
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,143 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { NextResponse } from 'next/server';
3
+ import type Stripe from 'stripe';
4
+ import { eq } from 'drizzle-orm';
5
+ import { stripe } from '@/lib/stripe';
6
+ import { recordInvoicePayment } from '@/lib/invoices/actions';
7
+ import { db } from '@/db/client';
8
+ import { servicePlanSubscriptions } from '@/db/schema';
9
+
10
+ /**
11
+ * Stripe webhook with both invoice reconciliation AND service-plan
12
+ * subscription handling. service-plans module ships this as a wholesale
13
+ * override of the estimates-invoices version (which itself overrode the
14
+ * payments-stripe baseline). resolveInstallers order: payments-stripe →
15
+ * estimates-invoices → service-plans (last-wins).
16
+ *
17
+ * Set STRIPE_WEBHOOK_SECRET in .env.local. For local dev:
18
+ * stripe listen --forward-to localhost:3000/api/stripe/webhook
19
+ */
20
+ export async function POST(req: NextRequest) {
21
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
22
+ if (!secret) {
23
+ return NextResponse.json({ error: 'STRIPE_WEBHOOK_SECRET not set' }, { status: 500 });
24
+ }
25
+ const signature = req.headers.get('stripe-signature');
26
+ if (!signature) {
27
+ return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
28
+ }
29
+ const body = await req.text();
30
+ let event: Stripe.Event;
31
+ try {
32
+ event = stripe.webhooks.constructEvent(body, signature, secret);
33
+ } catch (err) {
34
+ const message = err instanceof Error ? err.message : 'Signature verification failed';
35
+ return NextResponse.json({ error: message }, { status: 400 });
36
+ }
37
+
38
+ switch (event.type) {
39
+ case 'checkout.session.completed': {
40
+ const session = event.data.object as Stripe.Checkout.Session;
41
+
42
+ // Service-plan subscription Checkout → create the row in our DB.
43
+ if (session.mode === 'subscription' && session.metadata?.kind === 'service-plan-subscription') {
44
+ const customerId = session.metadata.customerId;
45
+ const planId = session.metadata.planId;
46
+ const stripeSubscriptionId =
47
+ typeof session.subscription === 'string' ? session.subscription : session.subscription?.id;
48
+ const stripeCustomerId =
49
+ typeof session.customer === 'string' ? session.customer : session.customer?.id;
50
+ if (!customerId || !planId || !stripeSubscriptionId) {
51
+ console.error('[stripe] subscription session missing metadata', session.id);
52
+ return NextResponse.json({ error: 'missing metadata' }, { status: 400 });
53
+ }
54
+ try {
55
+ // Idempotency: if we've already recorded this Stripe subscription,
56
+ // skip the insert.
57
+ const existing = await db
58
+ .select({ id: servicePlanSubscriptions.id })
59
+ .from(servicePlanSubscriptions)
60
+ .where(eq(servicePlanSubscriptions.stripeSubscriptionId, stripeSubscriptionId))
61
+ .limit(1);
62
+ if (existing.length === 0) {
63
+ // Renewal date = +1 of the billing interval. For simplicity we
64
+ // default to +1 year; Stripe's customer.subscription.updated
65
+ // event below will refine it from current_period_end.
66
+ const renewsAt = new Date();
67
+ renewsAt.setFullYear(renewsAt.getFullYear() + 1);
68
+ await db.insert(servicePlanSubscriptions).values({
69
+ customerId,
70
+ planId,
71
+ status: 'active',
72
+ stripeSubscriptionId,
73
+ stripeCustomerId: stripeCustomerId ?? null,
74
+ renewsAt,
75
+ });
76
+ console.log(`[stripe] subscribed customer ${customerId} to plan ${planId}`);
77
+ }
78
+ } catch (err) {
79
+ console.error('[stripe] subscription insert failed:', err);
80
+ return NextResponse.json({ error: 'subscription insert failed' }, { status: 500 });
81
+ }
82
+ break;
83
+ }
84
+
85
+ // Otherwise: invoice payment (handled by estimates-invoices logic).
86
+ const invoiceId = session.metadata?.invoiceId;
87
+ const amount = session.amount_total;
88
+ const paymentIntentId =
89
+ typeof session.payment_intent === 'string' ? session.payment_intent : null;
90
+ if (invoiceId && amount && amount > 0) {
91
+ try {
92
+ await recordInvoicePayment({
93
+ invoiceId,
94
+ amount,
95
+ method: 'stripe',
96
+ stripeSessionId: session.id,
97
+ stripePaymentIntentId: paymentIntentId ?? undefined,
98
+ note: 'Stripe Checkout',
99
+ });
100
+ console.log(`[stripe] reconciled invoice ${invoiceId} +${amount}¢`);
101
+ } catch (err) {
102
+ console.error(`[stripe] failed to record payment for invoice ${invoiceId}:`, err);
103
+ return NextResponse.json({ error: 'payment recording failed' }, { status: 500 });
104
+ }
105
+ } else {
106
+ console.log(
107
+ '[stripe] checkout.session.completed (no invoiceId metadata)',
108
+ session.id,
109
+ session.metadata,
110
+ );
111
+ }
112
+ break;
113
+ }
114
+ case 'customer.subscription.updated': {
115
+ const sub = event.data.object as Stripe.Subscription;
116
+ const renewsAt = sub.current_period_end ? new Date(sub.current_period_end * 1000) : null;
117
+ const status =
118
+ sub.status === 'active' || sub.status === 'trialing' ? 'active' :
119
+ sub.status === 'paused' ? 'paused' : 'cancelled';
120
+ await db
121
+ .update(servicePlanSubscriptions)
122
+ .set({ renewsAt, status, updatedAt: new Date() })
123
+ .where(eq(servicePlanSubscriptions.stripeSubscriptionId, sub.id));
124
+ break;
125
+ }
126
+ case 'customer.subscription.deleted': {
127
+ const sub = event.data.object as Stripe.Subscription;
128
+ await db
129
+ .update(servicePlanSubscriptions)
130
+ .set({ status: 'cancelled', cancelledAt: new Date(), updatedAt: new Date() })
131
+ .where(eq(servicePlanSubscriptions.stripeSubscriptionId, sub.id));
132
+ break;
133
+ }
134
+ case 'payment_intent.succeeded':
135
+ case 'payment_intent.payment_failed':
136
+ case 'invoice.paid':
137
+ break;
138
+ default:
139
+ break;
140
+ }
141
+
142
+ return NextResponse.json({ received: true });
143
+ }
@@ -0,0 +1,126 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import Link from 'next/link';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { createServicePlan } from '@/lib/service-plans/actions';
10
+ import {
11
+ BILLING_INTERVALS,
12
+ BILLING_INTERVAL_LABEL,
13
+ type BillingInterval,
14
+ } from '@/lib/service-plans/types';
15
+
16
+ export function NewServicePlanForm() {
17
+ const [pending, start] = useTransition();
18
+ const [error, setError] = useState<string | null>(null);
19
+
20
+ const [name, setName] = useState('');
21
+ const [description, setDescription] = useState('');
22
+ const [price, setPrice] = useState(2499);
23
+ const [billingInterval, setBillingInterval] = useState<BillingInterval>('monthly');
24
+ const [visitsPerYear, setVisitsPerYear] = useState(2);
25
+ const [perks, setPerks] = useState('');
26
+
27
+ function handleSubmit(e: React.FormEvent) {
28
+ e.preventDefault();
29
+ setError(null);
30
+ if (!name.trim()) { setError('Name is required.'); return; }
31
+ start(async () => {
32
+ try {
33
+ await createServicePlan({
34
+ name,
35
+ description: description || undefined,
36
+ price,
37
+ billingInterval,
38
+ visitsPerYear,
39
+ perks: perks || undefined,
40
+ });
41
+ } catch (err) {
42
+ setError((err as Error).message);
43
+ }
44
+ });
45
+ }
46
+
47
+ return (
48
+ <form onSubmit={handleSubmit} className="space-y-6">
49
+ <Card>
50
+ <CardHeader>
51
+ <CardTitle>Plan basics</CardTitle>
52
+ <CardDescription>Name and what the customer gets.</CardDescription>
53
+ </CardHeader>
54
+ <CardContent className="space-y-4">
55
+ <div className="space-y-2">
56
+ <Label htmlFor="name">Name *</Label>
57
+ <Input id="name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Gold Plan" />
58
+ </div>
59
+ <div className="space-y-2">
60
+ <Label htmlFor="description">Description</Label>
61
+ <Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="One-line summary of the plan" />
62
+ </div>
63
+ </CardContent>
64
+ </Card>
65
+
66
+ <Card>
67
+ <CardHeader>
68
+ <CardTitle>Pricing & cadence</CardTitle>
69
+ <CardDescription>Prices in cents. e.g. 2499 = $24.99</CardDescription>
70
+ </CardHeader>
71
+ <CardContent className="space-y-4">
72
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
73
+ <div className="space-y-2">
74
+ <Label htmlFor="price">Price (¢)</Label>
75
+ <Input id="price" type="number" min={0} value={price} onChange={(e) => setPrice(Number(e.target.value) || 0)} />
76
+ </div>
77
+ <div className="space-y-2">
78
+ <Label htmlFor="billingInterval">Billing interval</Label>
79
+ <select
80
+ id="billingInterval"
81
+ value={billingInterval}
82
+ onChange={(e) => setBillingInterval(e.target.value as BillingInterval)}
83
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
84
+ >
85
+ {BILLING_INTERVALS.map((i) => (
86
+ <option key={i} value={i}>{BILLING_INTERVAL_LABEL[i]}</option>
87
+ ))}
88
+ </select>
89
+ </div>
90
+ </div>
91
+ <div className="space-y-2">
92
+ <Label htmlFor="visitsPerYear">Visits per year</Label>
93
+ <Input id="visitsPerYear" type="number" min={0} value={visitsPerYear} onChange={(e) => setVisitsPerYear(Number(e.target.value) || 0)} />
94
+ </div>
95
+ </CardContent>
96
+ </Card>
97
+
98
+ <Card>
99
+ <CardHeader>
100
+ <CardTitle>Perks</CardTitle>
101
+ <CardDescription>Separated by · (the · character renders as a bulleted list)</CardDescription>
102
+ </CardHeader>
103
+ <CardContent>
104
+ <textarea
105
+ value={perks}
106
+ onChange={(e) => setPerks(e.target.value)}
107
+ rows={3}
108
+ placeholder="Annual tune-up · 15% off repairs · Priority booking"
109
+ className="border-input bg-background focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
110
+ />
111
+ </CardContent>
112
+ </Card>
113
+
114
+ {error && <p className="text-destructive text-sm">{error}</p>}
115
+
116
+ <div className="flex justify-end gap-3">
117
+ <Button type="button" variant="outline" asChild>
118
+ <Link href="/service-plans">Cancel</Link>
119
+ </Button>
120
+ <Button type="submit" disabled={pending}>
121
+ {pending ? 'Creating…' : 'Create plan'}
122
+ </Button>
123
+ </div>
124
+ </form>
125
+ );
126
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useState, useTransition } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6
+ import { Label } from '@/components/ui/label';
7
+ import { subscribeCustomerWithStripeCheckout, subscribeCustomerToPlan } from '@/lib/service-plans/actions';
8
+
9
+ interface Props {
10
+ planId: string;
11
+ customers: { id: string; name: string }[];
12
+ }
13
+
14
+ export function SubscribeCustomerForm({ planId, customers }: Props) {
15
+ const [customerId, setCustomerId] = useState(customers[0]?.id ?? '');
16
+ const [pending, start] = useTransition();
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [info, setInfo] = useState<string | null>(null);
19
+
20
+ function handleStripe() {
21
+ setError(null);
22
+ setInfo(null);
23
+ if (!customerId) { setError('Pick a customer.'); return; }
24
+ start(async () => {
25
+ try {
26
+ // Redirects to Stripe Checkout on success — no return.
27
+ await subscribeCustomerWithStripeCheckout(customerId, planId);
28
+ } catch (err) {
29
+ setError((err as Error).message);
30
+ }
31
+ });
32
+ }
33
+
34
+ function handleManual() {
35
+ setError(null);
36
+ setInfo(null);
37
+ if (!customerId) { setError('Pick a customer.'); return; }
38
+ if (!confirm('Subscribe without billing — record this subscription as paid offline?')) return;
39
+ start(async () => {
40
+ try {
41
+ await subscribeCustomerToPlan(customerId, planId);
42
+ setInfo('Subscription recorded.');
43
+ } catch (err) {
44
+ setError((err as Error).message);
45
+ }
46
+ });
47
+ }
48
+
49
+ return (
50
+ <Card>
51
+ <CardHeader>
52
+ <CardTitle>Subscribe a customer</CardTitle>
53
+ <CardDescription>
54
+ Bill via Stripe Checkout (recurring) — or record an offline subscription if the customer pays you directly.
55
+ </CardDescription>
56
+ </CardHeader>
57
+ <CardContent className="space-y-4">
58
+ <div className="space-y-2">
59
+ <Label htmlFor="customer">Customer</Label>
60
+ {customers.length === 0 ? (
61
+ <p className="text-muted-foreground text-sm">No customers yet.</p>
62
+ ) : (
63
+ <select
64
+ id="customer"
65
+ value={customerId}
66
+ onChange={(e) => setCustomerId(e.target.value)}
67
+ className="border-input bg-background h-10 w-full rounded-md border px-3 text-sm"
68
+ >
69
+ {customers.map((c) => (
70
+ <option key={c.id} value={c.id}>{c.name}</option>
71
+ ))}
72
+ </select>
73
+ )}
74
+ </div>
75
+ {error && <p className="text-destructive text-sm">{error}</p>}
76
+ {info && <p className="text-emerald-600 text-sm">✓ {info}</p>}
77
+ <div className="flex flex-wrap items-center gap-3">
78
+ <Button onClick={handleStripe} disabled={pending || customers.length === 0}>
79
+ {pending ? 'Opening checkout…' : 'Subscribe via Stripe Checkout'}
80
+ </Button>
81
+ <Button onClick={handleManual} variant="outline" disabled={pending || customers.length === 0}>
82
+ Record offline subscription
83
+ </Button>
84
+ </div>
85
+ </CardContent>
86
+ </Card>
87
+ );
88
+ }
@@ -0,0 +1,12 @@
1
+ import { asc } from 'drizzle-orm';
2
+ import { db } from '@/db/client';
3
+ import { customers } from '@/db/schema';
4
+ import { SubscribeCustomerForm } from './subscribe-customer-form';
5
+
6
+ export async function SubscribeCustomerSection({ planId }: { planId: string }) {
7
+ const rows = await db
8
+ .select({ id: customers.id, name: customers.name })
9
+ .from(customers)
10
+ .orderBy(asc(customers.name));
11
+ return <SubscribeCustomerForm planId={planId} customers={rows} />;
12
+ }
@@ -0,0 +1,46 @@
1
+ import { integer, pgEnum, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
+ import { customers } from './customers';
3
+
4
+ export const billingInterval = pgEnum('billing_interval', ['monthly', 'quarterly', 'annual']);
5
+ export const subscriptionStatus = pgEnum('subscription_status', ['active', 'paused', 'cancelled']);
6
+
7
+ export const servicePlans = pgTable('service_plans', {
8
+ id: uuid('id').primaryKey().defaultRandom(),
9
+ name: text('name').notNull(),
10
+ description: text('description'),
11
+ /** Price billed per `billing_interval`, in cents. */
12
+ price: integer('price').notNull().default(0),
13
+ billingInterval: billingInterval('billing_interval').notNull().default('monthly'),
14
+ visitsPerYear: integer('visits_per_year').notNull().default(1),
15
+ perks: text('perks'),
16
+ /** Stripe Product ID — set on first Subscribe-customer call via
17
+ * ensureStripePriceForPlan. Null until then. */
18
+ stripeProductId: text('stripe_product_id'),
19
+ /** Stripe Price ID — recurring price tied to billing_interval. */
20
+ stripePriceId: text('stripe_price_id'),
21
+ archivedAt: timestamp('archived_at'),
22
+ createdAt: timestamp('created_at').notNull().defaultNow(),
23
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
24
+ });
25
+
26
+ export const servicePlanSubscriptions = pgTable('service_plan_subscriptions', {
27
+ id: uuid('id').primaryKey().defaultRandom(),
28
+ customerId: uuid('customer_id').notNull().references(() => customers.id, { onDelete: 'cascade' }),
29
+ planId: uuid('plan_id').notNull().references(() => servicePlans.id, { onDelete: 'restrict' }),
30
+ status: subscriptionStatus('status').notNull().default('active'),
31
+ startedAt: timestamp('started_at').notNull().defaultNow(),
32
+ renewsAt: timestamp('renews_at'),
33
+ cancelledAt: timestamp('cancelled_at'),
34
+ notes: text('notes'),
35
+ /** Stripe Customer ID — created on first subscription via Checkout. */
36
+ stripeCustomerId: text('stripe_customer_id'),
37
+ /** Stripe Subscription ID — set by the webhook on checkout completion. */
38
+ stripeSubscriptionId: text('stripe_subscription_id').unique(),
39
+ createdAt: timestamp('created_at').notNull().defaultNow(),
40
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
41
+ });
42
+
43
+ export type ServicePlanRow = typeof servicePlans.$inferSelect;
44
+ export type NewServicePlanRow = typeof servicePlans.$inferInsert;
45
+ export type SubscriptionRow = typeof servicePlanSubscriptions.$inferSelect;
46
+ export type NewSubscriptionRow = typeof servicePlanSubscriptions.$inferInsert;
@@ -0,0 +1,196 @@
1
+ 'use server';
2
+
3
+ import { and, eq } from 'drizzle-orm';
4
+ import { headers } from 'next/headers';
5
+ import { redirect } from 'next/navigation';
6
+ import { revalidatePath } from 'next/cache';
7
+ import { db } from '@/db/client';
8
+ import { customers, servicePlans, servicePlanSubscriptions } from '@/db/schema';
9
+ import { isStripeConfigured, stripe } from '@/lib/stripe';
10
+ import type { BillingInterval, SubscriptionStatus } from './types';
11
+
12
+ export interface CreateServicePlanInput {
13
+ name: string;
14
+ description?: string;
15
+ price: number; // cents
16
+ billingInterval: BillingInterval;
17
+ visitsPerYear: number;
18
+ perks?: string;
19
+ }
20
+
21
+ export async function createServicePlan(input: CreateServicePlanInput): Promise<void> {
22
+ if (!input.name.trim()) throw new Error('Name is required');
23
+ if (input.price < 0) throw new Error('Price must be ≥ 0');
24
+
25
+ const [row] = await db
26
+ .insert(servicePlans)
27
+ .values({
28
+ name: input.name.trim(),
29
+ description: input.description?.trim() || null,
30
+ price: input.price,
31
+ billingInterval: input.billingInterval,
32
+ visitsPerYear: input.visitsPerYear,
33
+ perks: input.perks?.trim() || null,
34
+ })
35
+ .returning({ id: servicePlans.id });
36
+
37
+ revalidatePath('/service-plans');
38
+ redirect(`/service-plans/${row.id}`);
39
+ }
40
+
41
+ /**
42
+ * Manual subscription — no Stripe billing. Used for cash/check plans or
43
+ * when the office records a subscription that was sold offline.
44
+ */
45
+ export async function subscribeCustomerToPlan(
46
+ customerId: string,
47
+ planId: string,
48
+ ): Promise<string> {
49
+ if (!customerId || !planId) throw new Error('Customer and plan are required');
50
+ const renewsAt = new Date();
51
+ renewsAt.setFullYear(renewsAt.getFullYear() + 1);
52
+ const [row] = await db
53
+ .insert(servicePlanSubscriptions)
54
+ .values({
55
+ customerId,
56
+ planId,
57
+ status: 'active',
58
+ renewsAt,
59
+ })
60
+ .returning({ id: servicePlanSubscriptions.id });
61
+ revalidatePath('/service-plans');
62
+ revalidatePath(`/service-plans/${planId}`);
63
+ revalidatePath(`/customers/${customerId}`);
64
+ return row.id;
65
+ }
66
+
67
+ export async function setSubscriptionStatus(
68
+ subscriptionId: string,
69
+ status: SubscriptionStatus,
70
+ ): Promise<void> {
71
+ await db
72
+ .update(servicePlanSubscriptions)
73
+ .set({
74
+ status,
75
+ cancelledAt: status === 'cancelled' ? new Date() : null,
76
+ updatedAt: new Date(),
77
+ })
78
+ .where(eq(servicePlanSubscriptions.id, subscriptionId));
79
+ revalidatePath('/service-plans');
80
+ }
81
+
82
+ /**
83
+ * Idempotently ensures the given service plan has a Stripe Product + Price
84
+ * (recurring). Returns the price ID. Stripe Prices are immutable — if a
85
+ * plan's price/interval is edited later, this will create a NEW Price
86
+ * rather than mutate the old one (and you should update plan.stripePriceId).
87
+ */
88
+ async function ensureStripePriceForPlan(planId: string): Promise<string> {
89
+ if (!isStripeConfigured) {
90
+ throw new Error('Stripe not configured. Set STRIPE_SECRET_KEY in .env.local.');
91
+ }
92
+ const [plan] = await db.select().from(servicePlans).where(eq(servicePlans.id, planId)).limit(1);
93
+ if (!plan) throw new Error('Plan not found');
94
+ if (plan.stripePriceId) return plan.stripePriceId;
95
+
96
+ const product = plan.stripeProductId
97
+ ? await stripe.products.retrieve(plan.stripeProductId)
98
+ : await stripe.products.create({
99
+ name: plan.name,
100
+ description: plan.description ?? undefined,
101
+ metadata: { fielderlyPlanId: plan.id },
102
+ });
103
+
104
+ const intervalMap: Record<BillingInterval, 'month' | 'year'> = {
105
+ monthly: 'month',
106
+ quarterly: 'month', // Stripe doesn't have 'quarter' — use month × 3
107
+ annual: 'year',
108
+ };
109
+ const intervalCount = plan.billingInterval === 'quarterly' ? 3 : 1;
110
+
111
+ const price = await stripe.prices.create({
112
+ product: product.id,
113
+ currency: 'usd',
114
+ unit_amount: plan.price,
115
+ recurring: {
116
+ interval: intervalMap[plan.billingInterval as BillingInterval],
117
+ interval_count: intervalCount,
118
+ },
119
+ metadata: { fielderlyPlanId: plan.id },
120
+ });
121
+
122
+ await db
123
+ .update(servicePlans)
124
+ .set({ stripeProductId: product.id, stripePriceId: price.id, updatedAt: new Date() })
125
+ .where(eq(servicePlans.id, plan.id));
126
+
127
+ return price.id;
128
+ }
129
+
130
+ /**
131
+ * Server action — initiates a Stripe Checkout session in subscription
132
+ * mode for the given customer + plan. On successful payment the webhook
133
+ * inserts the servicePlanSubscriptions row with the resulting Stripe
134
+ * subscription ID and customer ID.
135
+ */
136
+ export async function subscribeCustomerWithStripeCheckout(
137
+ customerId: string,
138
+ planId: string,
139
+ ): Promise<void> {
140
+ if (!customerId || !planId) throw new Error('Customer and plan required');
141
+ if (!isStripeConfigured) {
142
+ throw new Error('Stripe not configured. Set STRIPE_SECRET_KEY in .env.local.');
143
+ }
144
+
145
+ // Make sure we haven't already subscribed this customer to this plan.
146
+ const existing = await db
147
+ .select({ id: servicePlanSubscriptions.id })
148
+ .from(servicePlanSubscriptions)
149
+ .where(
150
+ and(
151
+ eq(servicePlanSubscriptions.customerId, customerId),
152
+ eq(servicePlanSubscriptions.planId, planId),
153
+ eq(servicePlanSubscriptions.status, 'active'),
154
+ ),
155
+ )
156
+ .limit(1);
157
+ if (existing.length > 0) {
158
+ throw new Error('Customer is already actively subscribed to this plan.');
159
+ }
160
+
161
+ const priceId = await ensureStripePriceForPlan(planId);
162
+ const [customer] = await db
163
+ .select({ id: customers.id, name: customers.name, emails: customers.emails })
164
+ .from(customers)
165
+ .where(eq(customers.id, customerId))
166
+ .limit(1);
167
+ if (!customer) throw new Error('Customer not found');
168
+ const email = Array.isArray(customer.emails) ? customer.emails[0] : undefined;
169
+
170
+ const h = await headers();
171
+ const host = h.get('host') ?? 'localhost:3000';
172
+ const proto = h.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https');
173
+ const origin = `${proto}://${host}`;
174
+
175
+ const session = await stripe.checkout.sessions.create({
176
+ mode: 'subscription',
177
+ customer_email: email,
178
+ line_items: [{ price: priceId, quantity: 1 }],
179
+ success_url: `${origin}/service-plans/${planId}?subscribed=true`,
180
+ cancel_url: `${origin}/service-plans/${planId}?cancelled=true`,
181
+ metadata: {
182
+ planId,
183
+ customerId,
184
+ kind: 'service-plan-subscription',
185
+ },
186
+ subscription_data: {
187
+ metadata: {
188
+ planId,
189
+ customerId,
190
+ },
191
+ },
192
+ });
193
+
194
+ if (!session.url) throw new Error('Stripe did not return a checkout URL');
195
+ redirect(session.url);
196
+ }