Billing System
Overview
The billing system in Gratheon enables users to subscribe to different pricing tiers (Free, Starter, Professional) and manage their subscriptions. The system is implemented across three repositories:
- web-app: Frontend user interface for billing management
- user-cycle: Backend GraphQL API and Stripe integration
- website: Public-facing pricing page
Architecture
System Components
graph TB
subgraph "Frontend (web-app)"
UI[Account/Billing Page]
PricingPlans[PricingPlans Component]
BillingConfig[billing.ts Config]
end
subgraph "Backend (user-cycle)"
GraphQL[GraphQL Resolvers]
StripeAPI[Stripe Integration]
UserDB[(User Database)]
end
subgraph "External"
Stripe[Stripe Checkout]
StripeWebhook[Stripe Webhooks]
end
UI --> PricingPlans
PricingPlans --> BillingConfig
PricingPlans -->|createCheckoutSession| GraphQL
GraphQL --> StripeAPI
StripeAPI --> Stripe
Stripe -->|Payment Success| StripeWebhook
StripeWebhook --> GraphQL
GraphQL --> UserDB
Pricing Tiers
Free (Hobbyist)
- Price: Free forever
- Features:
- Up to 3 hives
- 10 frames per hive max
- Worker bee detection
- Queen detection
- Public hive sharing
- Treatment diary
- Limitations:
- Low-priority AI processing
- 1 year image retention
Starter
- Monthly: €22/month
- Yearly: €12/month (€144/year) - Save 45%
- Features:
- Up to 20 hives
- 30 frames per hive
- Cell analysis
- Varroa counting (bottom board)
- Hive placement planner
- Inspection management
- AI beekeeping assistant
- Limitations:
- 1 user account
- 2 year image retention
Professional
- Monthly: €55/month
- Yearly: €33/month (€396/year) - Save 40%
- Features:
- Up to 150 hives
- Unlimited frames
- Telemetry storage
- Timeseries data analytics
- Colony comparison
- Unlimited inspections
- Up to 20 user accounts
- Limitations:
- 10 min telemetry resolution
- 3 year image retention
- Status: In Development
Flexible (Addon)
- Price: €100 one-time (1000 tokens, valid 1 year)
- Purpose: Pay-per-use infrastructure features
- Features:
- Video processing & storage
- IoT telemetry rate limits
- SMS alerts
- Webhook integrations
- Extra capacity beyond tier limits
- Status: In Development (not shown in web-app billing selection)
Enterprise
- Price: Custom pricing
- Features:
- Custom integrations
- On-premise deployment
- 24/7 priority support
- Advanced security
- Unlimited scale
- Contact: enterprise@gratheon.com
Implementation Details
Frontend (web-app)
Configuration (src/config/billing.ts)
Defines all billing tiers with:
- Tier names and colors
- Monthly/yearly pricing
- Savings percentages
- Stripe price IDs
export const BILLING_TIERS = {
free: { name: 'Free', color: '#f0f0f0', textColor: '#666' },
starter: {
name: 'Starter',
color: '#FFD900',
monthly: { price: 22, currency: 'EUR', stripePrice: 'price_starter_monthly' },
yearly: {
price: 12,
pricePerYear: 144,
currency: 'EUR',
savings: '45%',
stripePrice: 'price_starter_yearly'
}
},
// ... more tiers
}
Components
src/page/accountEdit/billing/index.tsx
- Main billing page wrapper
- Shows subscription status
- Displays expiration dates
- Cancel subscription button
- Embeds PricingPlans component
src/page/accountEdit/billing/pricingPlans.tsx
- 3-column layout: Free, Starter, Professional
- Monthly/yearly toggle per tier
- Stripe checkout integration
- Current plan indication
- Error handling
src/page/accountEdit/billing/pricingPlans.css
- Responsive grid layout
- Tier-specific colors
- Hover effects
- Mobile-responsive
Backend (user-cycle)
GraphQL Schema (schema.graphql)
type User {
billingPlan: String
hasSubscription: Boolean
isSubscriptionExpired: Boolean
date_expiration: DateTime
}
type BillingHistoryEvent {
id: Int!
userId: Int!
eventType: String!
billingPlan: String
amount: Float
currency: String
details: String
createdAt: String!
}
type Query {
billingHistory: [BillingHistoryEvent]
}
type Mutation {
createCheckoutSession(plan: String, cycle: String): URL
cancelSubscription: CancelSubscriptionResult
}
Billing History
The billing history feature tracks all billing-related events for a user:
Event Types:
registration- User account createdpurchase- Tier subscription purchasedcancellation- Subscription cancelled by usersystem_downgrade- Auto-downgraded to free tier (e.g., payment failure)upgrade- Plan upgradeddowngrade- Plan downgraded
Model (src/models/billingHistory.ts):
export const billingHistoryModel = {
async getByUserId(userId: number): Promise<BillingHistoryEvent[]> {
// Fetch all events for user, ordered by createdAt DESC
},
async addRegistration(userId: number, plan: string) {
// Track when user registers
},
async addPurchase(userId: number, plan: string, amount: number, currency: string) {
// Track tier purchase
},
async addCancellation(userId: number, previousPlan: string) {
// Track cancellation
},
async addSystemDowngrade(userId: number, reason: string) {
// Track auto-downgrade to free tier
}
}
Database Table (billing_history):
id: Auto-increment primary keyuser_id: Foreign key to account tableevent_type: ENUM('registration', 'purchase', 'cancellation', 'system_downgrade', 'upgrade', 'downgrade')billing_plan: ENUM('free', 'starter', 'professional', 'addon', 'enterprise')amount: Decimal (payment amount)currency: VARCHAR (e.g., 'EUR')details: TEXT (JSON string with additional info)created_at: Timestamp
UI Display:
The billing history is shown as a timeline in the web-app /account page:
- Registration date
- Tier purchases with amounts
- Cancellations
- System changes (e.g., auto-downgrade on payment failure)
This replaces the need for explicit expiration warning messages - users can see their full billing timeline.
Resolvers (src/resolvers.ts)
createCheckoutSession
- Validates user authentication
- Maps plan/cycle to Stripe price ID
- Creates Stripe checkout session
- Returns checkout URL
- Supports modes: 'subscription' (starter, professional) or 'payment' (addon)
createCheckoutSession: async (parent, { plan, cycle }, ctx) => {
if (!ctx.uid) return err(error_code.AUTHENTICATION_REQUIRED);
const user = await userModel.getById(ctx.uid);
let priceId = getPriceIdForPlan(plan, cycle);
let mode = plan === 'addon' ? 'payment' : 'subscription';
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
mode: mode,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${appUrl}/account/success`,
cancel_url: `${appUrl}/account/cancel`,
metadata: { plan, cycle }
});
return session.url;
}
cancelSubscription
- Cancels Stripe subscription
- Updates user database
- Returns updated user object
Configuration (src/config/config.default.ts)
stripe: {
secret: 'sk_test_...',
webhook_secret: 'whsec_...',
plans: {
starter: {
monthly: 'price_starter_monthly_placeholder',
yearly: 'price_starter_yearly_placeholder'
},
professional: {
monthly: 'price_professional_monthly_placeholder',
yearly: 'price_professional_yearly_placeholder'
},
addon: {
oneTime: 'price_addon_onetime_placeholder'
}
}
}
Website (Public Pricing)
src/components/CustomPricingPage.js
- Public pricing page
- Shows all tiers: Hobbyist, Starter, Flexible, Professional, Enterprise
- Detailed feature lists
- Addon calculator (in development)
- Links to registration/sales
User Flow
Subscription Purchase Flow
sequenceDiagram
actor User
participant WebApp
participant UserCycle
participant Stripe
participant Database
User->>WebApp: Navigate to /account
WebApp->>UserCycle: Query current user & plan
UserCycle->>Database: Fetch user data
Database-->>UserCycle: User data
UserCycle-->>WebApp: User with billingPlan
User->>WebApp: Click "Choose Yearly" on Starter
WebApp->>UserCycle: createCheckoutSession(plan: "starter", cycle: "yearly")
UserCycle->>Stripe: Create checkout session
Stripe-->>UserCycle: Session URL
UserCycle-->>WebApp: Checkout URL
WebApp->>Stripe: Redirect to checkout
User->>Stripe: Complete payment
Stripe->>UserCycle: Webhook: payment_success
UserCycle->>Database: Update subscription
Stripe-->>User: Redirect to success_url
User->>WebApp: /account/success
WebApp->>WebApp: Show success message
Subscription Cancellation Flow
sequenceDiagram
actor User
participant WebApp
participant UserCycle
participant Stripe
participant Database
User->>WebApp: Click "Cancel subscription"
WebApp->>UserCycle: cancelSubscription mutation
UserCycle->>Database: Fetch user & stripe_subscription
Database-->>UserCycle: Subscription ID
UserCycle->>Stripe: Cancel subscription
Stripe-->>UserCycle: Confirmation
UserCycle->>Database: Set stripe_subscription = null
Database-->>UserCycle: Updated
UserCycle-->>WebApp: Updated user
WebApp->>WebApp: Update UI to show cancellation
Database Schema
account table (in user-cycle MySQL)
id: Primary keyemail: User emailstripe_subscription: Stripe subscription ID (nullable)billing_plan: ENUM('free', 'starter', 'professional', 'addon', 'enterprise') - Default: 'free'date_expiration: Subscription expiration datedate_added: Account creation date
billing_history table (in user-cycle MySQL)
id: Auto-increment primary keyuser_id: Foreign key to account tableevent_type: ENUM('registration', 'purchase', 'cancellation', 'system_downgrade', 'upgrade', 'downgrade')billing_plan: ENUM('free', 'starter', 'professional', 'addon', 'enterprise')amount: DECIMAL(10,2) - Payment amount (nullable)currency: VARCHAR(3) - e.g., 'EUR' (nullable)details: TEXT - JSON string with additional information (nullable)created_at: TIMESTAMP - Default: CURRENT_TIMESTAMP
Migration Files:
migrations/023-create-billing-history.sql- Creates table and backfills from account datamigrations/024-update-billing-plan-enum.sql- Updates enum values from old ('base', 'pro') to new ('starter', 'professional')
Stripe Integration
Price IDs (Production)
Must be configured in user-cycle/src/config/config.default.ts:
price_starter_monthly: Starter monthly planprice_starter_yearly: Starter yearly planprice_professional_monthly: Professional monthly planprice_professional_yearly: Professional yearly planprice_addon_onetime: Flexible addon one-time payment
Webhooks
Configure in Stripe Dashboard:
checkout.session.completed: Handle successful paymentscustomer.subscription.deleted: Handle cancellationscustomer.subscription.updated: Handle plan changes
Webhook endpoint: https://app.gratheon.com/webhooks/stripe
Environment Variables
user-cycle
STRIPE_SECRET_KEY: Stripe API secret keySTRIPE_WEBHOOK_SECRET: Stripe webhook signing secretJWT_KEY: Session token signing keyMYSQL_HOST,MYSQL_USER,MYSQL_PASSWORD,MYSQL_DATABASE: Database config
web-app
VITE_API_URL: GraphQL API endpoint (points to user-cycle)
Security Considerations
- Authentication: All billing mutations require valid JWT token in context
- CSRF Protection: GraphQL mutations use POST with proper CORS
- Webhook Verification: Stripe webhook signatures must be validated
- Price Integrity: Prices defined server-side, not client-side
- Session Security: Checkout sessions expire in 24 hours
Testing
Manual Testing Checklist
- Free tier displays correctly
- Starter monthly/yearly selection works
- Professional monthly/yearly selection works
- Stripe checkout redirects properly
- Success page shows after payment
- Cancel page shows if user abandons checkout
- Subscription cancellation works
- Expired subscription shows warning
- Current plan badge displays correctly
Stripe Test Cards
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - 3D Secure:
4000 0027 6000 3184
Known Issues & Future Improvements
Completed Features
- ✅ Billing history tracking (registration, purchase, cancellation events)
- ✅ Auto-downgrade to free tier on payment failure
- ✅ No hard paywalls - users maintain app access
- ✅ Updated pricing structure (45% yearly discount for Starter, 40% for Professional)
- ✅ Clean tier selection UI with current plan indicator
- ✅ Database enum migration (from 'base'/'pro' to 'starter'/'professional')
Current Work In Progress
- 🚧 Billing history timeline UI in web-app
- 🚧 Remove flexible tier from web-app selection (keep on website only)
- 🚧 Feature-level access control (replacing hard paywalls)
- 🚧 Improve tier selection visual design
Planned Features
- Subscription upgrade/downgrade flow
- Usage tracking for Flexible addon
- Invoice history in account page
- Payment method management
- Multi-currency support
- Enterprise custom contracts
- Prorated billing for plan changes
- Subscription renewal reminders
Feature-Level Blocking Strategy
Future implementation will check features individually:
// Example feature check
const { hasAccess, requiredTier } = useFeatureAccess('cell-analysis')
if (!hasAccess) {
return (
<UpgradePrompt
feature="Cell Analysis"
requiredTier={requiredTier}
description="Analyze honeycomb cells and track resources"
/>
)
}
Benefits:
- Users discover features naturally
- Contextual upgrade prompts show value
- Better UX than blocking entire app
- Clear feature-to-tier mapping
Deployment Notes
web-app
- Build:
npm run build - Environment: Set
VITE_API_URLto production user-cycle URL - Deploy static files to CDN/hosting
user-cycle
- Set production Stripe keys in config
- Configure Stripe webhook URL
- Deploy GraphQL service
- Verify database migrations
website
- Update pricing page if tiers change
- Rebuild Docusaurus:
npm run build - Deploy static site
Support & Troubleshooting
Common Issues
"Checkout session could not be created"
- Check Stripe API keys are correct
- Verify price IDs exist in Stripe Dashboard
- Check server logs for API errors
"Subscription has expired"
- User needs to renew subscription
- Check
date_expirationin database - Verify Stripe subscription status
Webhook not firing
- Verify webhook URL in Stripe Dashboard
- Check webhook secret is correct
- Inspect Stripe webhook logs
Contact
- Technical Issues: Create issue in respective repo
- Billing Support: support@gratheon.com
- Enterprise Sales: enterprise@gratheon.com