Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: updated pricing and billing #421

Merged
merged 5 commits into from
Dec 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading