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.
- package/README.md +53 -0
- package/dist/index.js +2341 -0
- package/package.json +51 -0
- package/template/base/_dot_gitignore +11 -0
- package/template/base/eslint.config.mjs +7 -0
- package/template/base/next.config.ts +17 -0
- package/template/base/package.json +35 -0
- package/template/base/postcss.config.mjs +7 -0
- package/template/base/scripts/setup.mjs +146 -0
- package/template/base/src/app/globals.css +68 -0
- package/template/base/src/app/layout.tsx +19 -0
- package/template/base/src/app/page.tsx +37 -0
- package/template/base/src/components/ui/badge.tsx +29 -0
- package/template/base/src/components/ui/button.tsx +49 -0
- package/template/base/src/components/ui/card.tsx +54 -0
- package/template/base/src/components/ui/input.tsx +19 -0
- package/template/base/src/components/ui/label.tsx +19 -0
- package/template/base/src/components/ui/separator.tsx +25 -0
- package/template/base/src/components/ui/skeleton.tsx +7 -0
- package/template/base/src/lib/utils.ts +17 -0
- package/template/base/tsconfig.json +21 -0
- package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
- package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
- package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
- package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
- package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
- package/template/extras/auth-better-auth/src/middleware.ts +25 -0
- package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
- package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
- package/template/extras/auth-clerk/src/middleware.ts +17 -0
- package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
- package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
- package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
- package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
- package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
- package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
- package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
- package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
- package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
- package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
- package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
- package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
- package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
- package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
- package/template/extras/customers/_fragments/convex.txt +28 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
- package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
- package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
- package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
- package/template/extras/customers/convex/convex/customers.ts +52 -0
- package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
- package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
- package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
- package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
- package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
- package/template/extras/db-convex/convex/_generated/README.md +13 -0
- package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
- package/template/extras/db-convex/convex/_generated/api.js +12 -0
- package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
- package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
- package/template/extras/db-convex/convex/_generated/server.js +12 -0
- package/template/extras/db-convex/convex/auth.config.ts +17 -0
- package/template/extras/db-convex/convex/schema.ts +28 -0
- package/template/extras/db-convex/src/app/layout.tsx +20 -0
- package/template/extras/db-convex/src/components/providers.tsx +28 -0
- package/template/extras/db-convex/src/lib/convex.ts +6 -0
- package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
- package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
- package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
- package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
- package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
- package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
- package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
- package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
- package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
- package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
- package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
- package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
- package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
- package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
- package/template/extras/jobs/_fragments/convex.txt +21 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
- package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
- package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
- package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
- package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
- package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
- package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
- package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
- package/template/extras/jobs/convex/convex/jobs.ts +46 -0
- package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
- package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
- package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
- package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
- package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
- package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
- package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
- package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
- package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
- package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
- package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
- package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
- package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
- package/template/extras/mobile/mobile/README.md +67 -0
- package/template/extras/mobile/mobile/_dot_env.example +5 -0
- package/template/extras/mobile/mobile/_dot_gitignore +26 -0
- package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
- package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
- package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
- package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
- package/template/extras/mobile/mobile/app/index.tsx +23 -0
- package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
- package/template/extras/mobile/mobile/app.brand.ts +14 -0
- package/template/extras/mobile/mobile/app.config.ts +40 -0
- package/template/extras/mobile/mobile/app.features.ts +11 -0
- package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
- package/template/extras/mobile/mobile/eas.json +22 -0
- package/template/extras/mobile/mobile/lib/api.ts +253 -0
- package/template/extras/mobile/mobile/lib/auth.ts +51 -0
- package/template/extras/mobile/mobile/lib/format.ts +23 -0
- package/template/extras/mobile/mobile/lib/push.ts +24 -0
- package/template/extras/mobile/mobile/lib/theme.ts +16 -0
- package/template/extras/mobile/mobile/package.json +34 -0
- package/template/extras/mobile/mobile/tsconfig.json +11 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
- package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
- package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
- package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
- package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
- package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
- package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
- package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
- package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
- package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
- package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
- package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
- package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
- package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
- package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
- package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
- package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
- package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
- package/template/extras/seed/_shared/scripts/seed.ts +35 -0
- package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
- package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
- package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
- package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
- package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
- 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
|
+
}
|