Skip to content

Commit

Permalink
Merge pull request #421 from phasehq/feat--updated-pricing
Browse files Browse the repository at this point in the history
feat: updated pricing and billing
  • Loading branch information
rohan-chaturvedi authored Dec 24, 2024
2 parents 8ff0934 + 64b7ad4 commit 33b858e
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 139 deletions.
12 changes: 11 additions & 1 deletion backend/ee/billing/graphene/queries/stripe.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from api.models import Organisation
from api.utils.access.permissions import user_has_permission
import graphene
from graphene import ObjectType, String, Boolean, List, Int
from graphene import ObjectType, String, Boolean, List, Int, Float
import stripe
from django.conf import settings
from graphql import GraphQLError
Expand Down Expand Up @@ -29,6 +29,7 @@ class StripeSubscriptionDetails(ObjectType):
subscription_id = String()
plan_name = String()
status = String()
next_payment_amount = Float()
current_period_start = Int()
current_period_end = Int()
renewal_date = Int()
Expand Down Expand Up @@ -110,6 +111,14 @@ def resolve_stripe_subscription_details(self, info, organisation_id):
for pm in payment_methods["data"]
]

# Retrieve upcoming invoice to get the amount of the next payment
upcoming_invoice = stripe.Invoice.upcoming(
customer=org.stripe_customer_id, subscription=org.stripe_subscription_id
)
next_payment_amount = upcoming_invoice[
"total"
] # Amount in the smallest currency unit

return StripeSubscriptionDetails(
subscription_id=org.stripe_subscription_id,
plan_name=plan_name,
Expand All @@ -120,6 +129,7 @@ def resolve_stripe_subscription_details(self, info, organisation_id):
cancel_at=str(cancel_at) if cancel_at else None,
cancel_at_period_end=cancel_at_period_end,
payment_methods=payment_methods_list,
next_payment_amount=next_payment_amount, # Add this field
)
except stripe.error.StripeError as e:
return None
1 change: 1 addition & 0 deletions frontend/apollo/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1793,6 +1793,7 @@ export type StripeSubscriptionDetails = {
cancelAtPeriodEnd?: Maybe<Scalars['Boolean']['output']>;
currentPeriodEnd?: Maybe<Scalars['Int']['output']>;
currentPeriodStart?: Maybe<Scalars['Int']['output']>;
nextPaymentAmount?: Maybe<Scalars['Float']['output']>;
paymentMethods?: Maybe<Array<Maybe<PaymentMethodDetails>>>;
planName?: Maybe<Scalars['String']['output']>;
renewalDate?: Maybe<Scalars['Int']['output']>;
Expand Down
1 change: 1 addition & 0 deletions frontend/apollo/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ type StripeSubscriptionDetails {
subscriptionId: String
planName: String
status: String
nextPaymentAmount: Float
currentPeriodStart: Int
currentPeriodEnd: Int
renewalDate: Int
Expand Down
98 changes: 0 additions & 98 deletions frontend/components/settings/organisation/PlanInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,72 +32,6 @@ import Accordion from '@/components/common/Accordion'
import clsx from 'clsx'
import { StripeBillingInfo } from '../../../ee/billing/StripeBillingInfo'

const plansInfo = {
FR: {
id: ApiOrganisationPlanChoices.Fr,
name: 'Free',
description: 'Try Phase without any commitments.',
seats: isCloudHosted() ? '5 Users / Service Accounts' : 'Unlimited Users',
apps: isCloudHosted() ? '3 Apps' : 'Unlimited Apps',
featureSummary: [
'End-to-end Encryption',
'Google/GitHub/Gitlab SSO',
'Secret Versioning',
'Secret Referencing',
'Basic Access Control',
isCloudHosted() ? '24-hour audit log retention' : 'Unlimited Audit Log Retention',
'Community Support',
],
notIncluded: [
...(isCloudHosted()
? [
'90-day audit log retention',
'Unlimited Users',
'Unlimited Apps',
'Unlimited Environments',
'Unlimited Service Tokens',
]
: []),
],
},
PR: {
id: ApiOrganisationPlanChoices.Pr,
name: 'Pro',
seats: 'Unlimited Users',
apps: 'Unlimited Apps',
featureSummary: [
'End-to-end Encryption',
'Google/GitHub/Gitlab SSO',
'Role-based Access Control',
'Secret Versioning',
'Secret Referencing',
isCloudHosted() ? '90-day audit log retention' : 'Unlimited Audit Log Retention',
'Priority Support',
],
notIncluded: [
...(isCloudHosted() ? ['Unlimited audit log retention', 'Unlimited Environments'] : []),
],
},
EN: {
id: ApiOrganisationPlanChoices.En,
name: 'Enterprise',
description:
'Secure existing data in your enterprise workload. Get full onboarding and priority technical support.',
seats: 'Unlimited Users',
apps: 'Unlimited Apps',
featureSummary: [
'End-to-end Encryption',
'Google/GitHub/Gitlab SSO',
'Role-based Access Control',
'Secret Versioning',
'Secret Referencing',
'Dedicated support',
'On-boarding and Migration assistance',
],
notIncluded: [],
},
}

const PlanFeatureItem = (props: {
children: ReactNode
iconColor: string
Expand Down Expand Up @@ -125,8 +59,6 @@ export const PlanInfo = () => {

const searchParams = useSearchParams()

const planInfo = activeOrganisation ? plansInfo[activeOrganisation.plan] : undefined

const { loading, data } = useQuery(GetOrganisationPlan, {
variables: { organisationId: activeOrganisation?.id },
skip: !activeOrganisation,
Expand Down Expand Up @@ -205,36 +137,6 @@ export const PlanInfo = () => {
{isCloudHosted() && <StripeBillingInfo />}
</div>
</div>

{planInfo && isCloudHosted() && (
<div className="grid grid-cols-2 gap-8">
<div>
<PlanFeatureItem iconColor="text-emerald-500" iconType="user">
{license()?.seats ? `${license()?.seats} Users / Service Accounts` : planInfo.seats}
</PlanFeatureItem>
<PlanFeatureItem iconColor="text-emerald-500" iconType="app">
{planInfo.apps}
</PlanFeatureItem>

{planInfo.featureSummary.map((feature) => (
<PlanFeatureItem key={feature} iconColor="text-emerald-500" iconType="check">
{feature}
</PlanFeatureItem>
))}
</div>

{planInfo.notIncluded.length > 0 && (
<div>
<div className="text-neutral-500 font-medium text-lg py-2">Not included:</div>
{planInfo.notIncluded.map((feature) => (
<PlanFeatureItem key={feature} iconColor="text-red-500" iconType="cross">
{feature}
</PlanFeatureItem>
))}
</div>
)}
</div>
)}
</div>

<div className="space-y-10 py-4">
Expand Down
169 changes: 130 additions & 39 deletions frontend/ee/billing/ProUpgradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ApiOrganisationPlanChoices } from '@/apollo/graphql'
import { Button } from '@/components/common/Button'
import { FaCartShopping } from 'react-icons/fa6'
import { ToggleSwitch } from '@/components/common/ToggleSwitch'
import clsx from 'clsx'

type BillingPeriods = 'monthly' | 'yearly'

Expand Down Expand Up @@ -47,8 +48,8 @@ const UpgradeForm = (props: { onSuccess: Function; billingPeriod: BillingPeriods
const prices: PriceOption[] = [
{
name: 'monthly',
unitPrice: 18,
monthlyPrice: 18,
unitPrice: 16,
monthlyPrice: 16,
},
{
name: 'yearly',
Expand All @@ -66,51 +67,141 @@ const ProUpgradeDialog = (props: { userCount: number; onSuccess: () => void }) =
else setCheckoutPreview('yearly')
}

const calculateGraduatedPrice = (seats: number) => {
const basePrice = 16

// Define graduated tiers with unit prices
const tiers = [
{ min: 0, max: 49, discount: 0 },
{ min: 50, max: 99, discount: 0.25 },
{ min: 100, max: 249, discount: 0.35 },
{ min: 250, max: 999, discount: 0.45 },
{ min: 1000, max: 2500, discount: 0.6 },
]

const calculateForTiers = (pricePerUnit: number) => {
let totalPrice = 0
let remainingSeats = seats

// eslint-disable-next-line no-restricted-syntax
for (const tier of tiers) {
if (remainingSeats <= 0) break

const seatsInTier = Math.min(remainingSeats, tier.max - tier.min + 1)
const tierPrice = pricePerUnit * (1 - tier.discount)
totalPrice += seatsInTier * tierPrice
remainingSeats -= seatsInTier
}

return totalPrice
}

// Calculate monthly cost
const monthlyCost = calculateForTiers(basePrice)

// Calculate annualized cost
const annualBasePrice = basePrice * 12
const annualCost = calculateForTiers(annualBasePrice)

const effectiveRateMonthly = monthlyCost / seats
const effectiveRateAnnual = annualCost / seats

const effectiveDiscountMonthly = ((basePrice - effectiveRateMonthly) / basePrice) * 100
const effectiveDiscountAnnual =
((annualBasePrice / 12 - effectiveRateAnnual / 12) / (annualBasePrice / 12)) * 100

return {
monthly: monthlyCost,
annually: annualCost,
effectiveRate: {
monthly: effectiveRateMonthly,
annually: effectiveRateAnnual / 12,
},
discount: {
monthly: effectiveDiscountMonthly,
annually: effectiveDiscountAnnual,
},
}
}

const priceToPreview = prices.find((price) => price.name === checkoutPreview)

const CheckoutPreview = ({ price }: { price: PriceOption }) => (
<div
key={price.name}
className="group shadow-xl bg-zinc-100 dark:bg-zinc-800 ring-1 ring-inset ring-neutral-500/40 rounded-lg p-4 space-y-6 transition ease"
>
<div className="flex items-start justify-between">
<div className="text-zinc-900 dark:text-zinc-100">
<span className="font-extralight text-7xl">${price.monthlyPrice}</span>
<span className="text-neutral-500">/mo per account</span>
</div>
<div>
<div className="text-neutral-500 text-xs uppercase font-medium">Billed</div>
<div className="flex items-center justify-center gap-2 text-zinc-900 dark:text-zinc-100 text-xs">
<div>Monthly</div>
<ToggleSwitch value={checkoutPreview === 'yearly'} onToggle={toggleCheckoutPreview} />
<div>Yearly</div>
const CheckoutPreview = ({ price }: { price: PriceOption }) => {
const graduatedPrice = calculateGraduatedPrice(props.userCount)

return (
<div
key={price.name}
className="group shadow-xl bg-zinc-100 dark:bg-zinc-800 ring-1 ring-inset ring-neutral-500/40 rounded-lg p-4 space-y-6 transition ease"
>
<div className="flex items-start justify-between">
<div className="text-zinc-900 dark:text-zinc-100">
<span className="font-extralight text-7xl">
$
{checkoutPreview === 'monthly'
? graduatedPrice.effectiveRate.monthly.toFixed(2)
: graduatedPrice.effectiveRate.annually.toFixed(2)}
</span>
<span className="text-neutral-500">/mo per account</span>
</div>
<div>
<div className="text-neutral-500 text-xs uppercase font-medium">Billed</div>
<div className="flex items-center justify-center gap-2 text-zinc-900 dark:text-zinc-100 text-xs">
<div>Monthly</div>
<ToggleSwitch value={checkoutPreview === 'yearly'} onToggle={toggleCheckoutPreview} />
<div>Yearly</div>
</div>
</div>
</div>
</div>

<div className="bg-zinc-200 dark:bg-zinc-700 p-3 rounded-lg mt-4 text-zinc-900 dark:text-zinc-100 text-xs space-y-1">
<div className="flex justify-between text-zinc-900 dark:text-zinc-100">
<span>Unit Price:</span>
<span>${price.unitPrice}</span>
</div>
<div className="flex justify-between text-zinc-900 dark:text-zinc-100">
<span>Number of Accounts:</span>
<span>{props.userCount}</span>
</div>
<hr className="my-2 border-zinc-300 dark:border-zinc-600" />
<div className="flex justify-between font-semibold text-zinc-900 dark:text-zinc-100">
<span>Total:</span>
<span>${price.unitPrice * props.userCount}</span>
<div className="bg-zinc-200 dark:bg-zinc-700 p-3 rounded-lg mt-4 text-zinc-900 dark:text-zinc-100 text-xs space-y-1">
<div className="flex justify-between text-zinc-900 dark:text-zinc-100">
<span>Avg Unit Price:</span>
<span>
$
{checkoutPreview === 'monthly'
? graduatedPrice.effectiveRate.monthly.toFixed(2)
: graduatedPrice.effectiveRate.annually.toFixed(2)}
</span>
</div>
<div className="flex justify-between text-zinc-900 dark:text-zinc-100">
<span>Number of Accounts:</span>
<span>{props.userCount}</span>
</div>
{graduatedPrice.discount.monthly > 0 && (
<div className="flex justify-between text-zinc-900 dark:text-zinc-100">
<span>Effective discount:</span>
<span className="text-emerald-500">
{graduatedPrice.discount.monthly.toFixed(1)}%
</span>
</div>
)}
<hr className="my-2 border-zinc-300 dark:border-zinc-600" />
<div className="flex justify-between font-semibold text-zinc-900 dark:text-zinc-100">
<span>Total:</span>
<span>
$
{checkoutPreview === 'monthly'
? graduatedPrice.monthly.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: graduatedPrice.annually.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
</div>
</div>

<div className="flex items-center gap-1 text-emerald-500 font-medium pt-4 justify-end">
<Button variant="primary" onClick={() => setBillingPeriod(price.name)}>
<FaCartShopping /> Checkout
</Button>
<div className="flex items-center gap-1 text-emerald-500 font-medium pt-4 justify-end">
<Button variant="primary" onClick={() => setBillingPeriod(price.name)}>
<FaCartShopping /> Checkout
</Button>
</div>
</div>
</div>
)
)
}

if (billingPeriod === null)
return (
Expand Down
2 changes: 1 addition & 1 deletion frontend/ee/billing/StripeBillingInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ export const StripeBillingInfo = () => {

<div className="text-neutral-500 text-sm flex items-center gap-1 font-semibold">
{!subscriptionData.cancelAtPeriodEnd
? `Next payment ${relativeTimeFromDates(new Date(subscriptionData.renewalDate! * 1000))}`
? `Next payment ${relativeTimeFromDates(new Date(subscriptionData.renewalDate! * 1000))} for $${(subscriptionData.nextPaymentAmount! / 100).toFixed(2)}`
: `Ends ${relativeTimeFromDates(new Date(subscriptionData.cancelAt! * 1000))}`}

{!subscriptionData.cancelAtPeriodEnd && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ query GetSubscriptionDetails($organisationId: ID!) {
subscriptionId
planName
status
nextPaymentAmount
currentPeriodStart
currentPeriodEnd
renewalDate
Expand Down

0 comments on commit 33b858e

Please sign in to comment.