Skip to content

Commit

Permalink
yearly plan
Browse files Browse the repository at this point in the history
  • Loading branch information
goenning committed Dec 1, 2022
1 parent 8e5cff3 commit 255c30e
Show file tree
Hide file tree
Showing 12 changed files with 159 additions and 43 deletions.
33 changes: 33 additions & 0 deletions app/actions/billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package actions

import (
"context"

"github.com/getfider/fider/app/models/entity"
"github.com/getfider/fider/app/pkg/env"

"github.com/getfider/fider/app/pkg/validate"
)

// GenerateCheckoutLink is used to generate a Paddle-hosted checkout link for the service subscription
type GenerateCheckoutLink struct {
PlanID string `json:"planId"`
}

// IsAuthorized returns true if current user is authorized to perform this action
func (action *GenerateCheckoutLink) IsAuthorized(ctx context.Context, user *entity.User) bool {
return user.IsAdministrator()
}

// Validate if current model is valid
func (action *GenerateCheckoutLink) Validate(ctx context.Context, user *entity.User) *validate.Result {
result := validate.Success()

if !env.IsBillingEnabled() {
result.AddFieldFailure("plan_id", "Billing is not enabled.")
} else if action.PlanID != env.Config.Paddle.MonthlyPlanID && action.PlanID != env.Config.Paddle.YearlyPlanID {
result.AddFieldFailure("plan_id", "Invalid Plan ID.")
}

return result
}
14 changes: 11 additions & 3 deletions app/handlers/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"

"github.com/getfider/fider/app/actions"
"github.com/getfider/fider/app/models/cmd"
"github.com/getfider/fider/app/models/dto"
"github.com/getfider/fider/app/models/enum"
Expand Down Expand Up @@ -42,9 +43,10 @@ func ManageBilling() web.HandlerFunc {
Title: "Manage Billing · Site Settings",
Data: web.Map{
"paddle": web.Map{
"isSandbox": env.Config.Paddle.IsSandbox,
"vendorId": env.Config.Paddle.VendorID,
"planId": env.Config.Paddle.PlanID,
"isSandbox": env.Config.Paddle.IsSandbox,
"vendorId": env.Config.Paddle.VendorID,
"monthlyPlanId": env.Config.Paddle.MonthlyPlanID,
"yearlyPlanId": env.Config.Paddle.YearlyPlanID,
},
"status": billingState.Result.Status,
"trialEndsAt": billingState.Result.TrialEndsAt,
Expand All @@ -58,7 +60,13 @@ func ManageBilling() web.HandlerFunc {
// GenerateCheckoutLink generates a Paddle-hosted checkout link for the service subscription
func GenerateCheckoutLink() web.HandlerFunc {
return func(c *web.Context) error {
action := new(actions.GenerateCheckoutLink)
if result := c.BindTo(action); !result.Ok {
return c.HandleValidation(result)
}

generateLink := &cmd.GenerateCheckoutLink{
PlanID: action.PlanID,
Passthrough: dto.PaddlePassthrough{
TenantID: c.Tenant().ID,
},
Expand Down
10 changes: 8 additions & 2 deletions app/handlers/billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/getfider/fider/app/models/query"
. "github.com/getfider/fider/app/pkg/assert"
"github.com/getfider/fider/app/pkg/bus"
"github.com/getfider/fider/app/pkg/env"
"github.com/getfider/fider/app/pkg/mock"

"github.com/getfider/fider/app/handlers"
Expand Down Expand Up @@ -59,7 +60,7 @@ func TestManageBillingHandler_ReturnsCorrectBillingInformation(t *testing.T) {
LastFourDigits: "1111",
ExpiryDate: "10/2031",
},
LastPayment: entity.BillingLastPayment{
LastPayment: entity.BillingPayment{
Amount: float64(30),
Currency: "USD",
Date: "2021-11-09",
Expand Down Expand Up @@ -94,6 +95,11 @@ func TestManageBillingHandler_ReturnsCorrectBillingInformation(t *testing.T) {
func TestGenerateCheckoutLinkHandler(t *testing.T) {
RegisterT(t)

env.Config.Paddle.VendorID = "123"
env.Config.Paddle.VendorAuthCode = "456"
env.Config.Paddle.MonthlyPlanID = "PLAN_M"
env.Config.Paddle.YearlyPlanID = "PLAN_Y"

bus.AddHandler(func(ctx context.Context, c *cmd.GenerateCheckoutLink) error {
c.URL = "https://paddle.com/fake-checkout-url"
return nil
Expand All @@ -104,7 +110,7 @@ func TestGenerateCheckoutLinkHandler(t *testing.T) {
WithURL("http://demo.test.fider.io/_api/billing/checkout-link").
OnTenant(mock.DemoTenant).
AsUser(mock.JonSnow).
ExecuteAsJSON(handlers.GenerateCheckoutLink())
ExecutePostAsJSON(handlers.GenerateCheckoutLink(), "{ \"planID\": \"PLAN_M\" }")

Expect(code).Equals(http.StatusOK)
Expect(json.String("url")).Equals("https://paddle.com/fake-checkout-url")
Expand Down
1 change: 1 addition & 0 deletions app/models/cmd/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
)

type GenerateCheckoutLink struct {
PlanID string
Passthrough dto.PaddlePassthrough

// Output
Expand Down
5 changes: 3 additions & 2 deletions app/models/entity/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ type BillingSubscription struct {
UpdateURL string `json:"updateURL"`
CancelURL string `json:"cancelURL"`
PaymentInformation BillingPaymentInformation `json:"paymentInformation"`
LastPayment BillingLastPayment `json:"lastPayment"`
LastPayment BillingPayment `json:"lastPayment"`
NextPayment BillingPayment `json:"nextPayment"`
}

type BillingPaymentInformation struct {
Expand All @@ -28,7 +29,7 @@ type BillingPaymentInformation struct {
ExpiryDate string `json:"expiryDate"`
}

type BillingLastPayment struct {
type BillingPayment struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Date string `json:"date"`
Expand Down
3 changes: 2 additions & 1 deletion app/pkg/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ type config struct {
IsSandbox bool `env:"PADDLE_SANDBOX,default=false"`
VendorID string `env:"PADDLE_VENDOR_ID"`
VendorAuthCode string `env:"PADDLE_VENDOR_AUTHCODE"`
PlanID string `env:"PADDLE_PLAN_ID"`
MonthlyPlanID string `env:"PADDLE_MONTHLY_PLAN_ID"`
YearlyPlanID string `env:"PADDLE_YEARLY_PLAN_ID"`
PublicKey string `env:"PADDLE_PUBLIC_KEY"`
}
Metrics struct {
Expand Down
9 changes: 7 additions & 2 deletions app/services/billing/paddle/paddle.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func generateCheckoutLink(ctx context.Context, c *cmd.GenerateCheckoutLink) erro
params := url.Values{}
params.Set("vendor_id", env.Config.Paddle.VendorID)
params.Set("vendor_auth_code", env.Config.Paddle.VendorAuthCode)
params.Set("product_id", env.Config.Paddle.PlanID)
params.Set("product_id", c.PlanID)
params.Set("passthrough", string(passthrough))

req := &cmd.HTTPRequest{
Expand Down Expand Up @@ -137,11 +137,16 @@ func getBillingSubscription(ctx context.Context, q *query.GetBillingSubscription
LastFourDigits: sub[0].PaymentInformation.LastFourDigits,
ExpiryDate: sub[0].PaymentInformation.ExpiryDate,
},
LastPayment: entity.BillingLastPayment{
LastPayment: entity.BillingPayment{
Amount: sub[0].LastPayment.Amount,
Currency: sub[0].LastPayment.Currency,
Date: sub[0].LastPayment.Date,
},
NextPayment: entity.BillingPayment{
Amount: sub[0].NextPayment.Amount,
Currency: sub[0].NextPayment.Currency,
Date: sub[0].NextPayment.Date,
},
}
}
return nil
Expand Down
5 changes: 5 additions & 0 deletions app/services/billing/paddle/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ type PaddleSubscriptionItem struct {
Currency string `json:"currency"`
Date string `json:"date"`
} `json:"last_payment"`
NextPayment struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Date string `json:"date"`
} `json:"next_payment"`
}
27 changes: 19 additions & 8 deletions public/pages/Administration/hooks/use-paddle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { useEffect, useState } from "react"
interface UsePaddleParams {
isSandbox: boolean
vendorId: string
planId: string
monthlyPlanId: string
yearlyPlanId: string
}

export function usePaddle(params: UsePaddleParams) {
const status = useScript("https://cdn.paddle.com/paddle/paddle.js")
const [price, setPrice] = useCache("price", "$30")
const [monthlyPrice, setMonthlyPrice] = useCache("monthlyPrice", "$30")
const [yearlyPrice, setYearlyPrice] = useCache("yearlyPrice", "$300")
const [isReady, setIsReady] = useState(false)

useEffect(() => {
Expand All @@ -24,9 +26,11 @@ export function usePaddle(params: UsePaddleParams) {
window.Paddle.Setup({ vendor })
setIsReady(true)

const id = parseInt(params.planId, 10)
window.Paddle.Product.Prices(id, (resp) => {
setPrice(resp.price.net.replace(/\.00/g, ""))
window.Paddle.Product.Prices(parseInt(params.monthlyPlanId, 10), (resp) => {
setMonthlyPrice(resp.price.net.replace(/\.00/g, ""))
})
window.Paddle.Product.Prices(parseInt(params.yearlyPlanId, 10), (resp) => {
setYearlyPrice(resp.price.net.replace(/\.00/g, ""))
})
}, [status])

Expand All @@ -39,12 +43,19 @@ export function usePaddle(params: UsePaddleParams) {
})
}

const openCheckoutUrl = async () => {
const result = await actions.generateCheckoutLink()
const subscribeMonthly = async () => {
const result = await actions.generateCheckoutLink(params.monthlyPlanId)
if (result.ok) {
openUrl(result.data.url)
}
}

const subscribeYearly = async () => {
const result = await actions.generateCheckoutLink(params.yearlyPlanId)
if (result.ok) {
openUrl(result.data.url)
}
}

return { isReady, price, openUrl, openCheckoutUrl }
return { isReady, monthlyPrice, yearlyPrice, openUrl, subscribeMonthly, subscribeYearly }
}
76 changes: 58 additions & 18 deletions public/pages/Administration/pages/ManageBilling.page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react"
import { Button, Moment, Money } from "@fider/components"
import { VStack } from "@fider/components/layout"
import { HStack, VStack } from "@fider/components/layout"
import { useFider } from "@fider/hooks"
import { BillingStatus } from "@fider/models"
import { AdminPageContainer } from "../components/AdminBasePage"
Expand All @@ -11,7 +11,8 @@ interface ManageBillingPageProps {
paddle: {
isSandbox: boolean
vendorId: string
planId: string
monthlyPlanId: string
yearlyPlanId: string
}
status: BillingStatus
trialEndsAt: string
Expand All @@ -30,18 +31,50 @@ interface ManageBillingPageProps {
currency: string
date: string
}
nextPayment: {
amount: number
currency: string
date: string
}
}
}

const SubscribeButton = (props: { price: string; onClick: () => void }) => {
const SubscribePanel = (props: { monthlyPrice: string; subscribeMonthly: () => void; yearlyPrice: string; subscribeYearly: () => void }) => {
return (
<p>
<Button variant="primary" onClick={props.onClick}>
Subscribe for {props.price}/mo
</Button>

<span className="block text-muted">VAT/Tax may be added during checkout.</span>
</p>
<div>
<HStack spacing={4}>
<VStack spacing={4} className="py-2 px-4 shadow rounded text-center">
<div>
<span className="block text-xs p-1 rounded mb-2">&nbsp;</span>
<span className="text-category">Monthy Subscription</span>
</div>
<span className="text-display2 block">
{props.monthlyPrice}
<span className="text-title">/month</span>
</span>
<Button variant="secondary" size="small" className="mx-auto" onClick={props.subscribeMonthly}>
Subscribe
</Button>
</VStack>
<VStack spacing={4} className="py-2 px-4 shadow rounded text-center">
<div>
<span className="block text-xs bg-yellow-100 p-1 rounded mb-2">
<strong>2 months free!</strong>
</span>
<span className="text-category">Yearly Subscription</span>
</div>
<span className="text-display2 block">
{props.yearlyPrice}
<span className="text-title">/year</span>
</span>
<Button variant="secondary" size="small" className="mx-auto" onClick={props.subscribeYearly}>
Subscribe
</Button>
</VStack>
</HStack>

<span className="block mt-4 text-muted">VAT/Tax may be added during checkout.</span>
</div>
)
}

Expand All @@ -60,13 +93,13 @@ const ActiveSubscriptionInformation = (props: ManageBillingPageProps) => {
<h3 className="text-display">Your subscription is Active</h3>
<CardDetails {...props.subscription.paymentInformation} />
<p>
Last payment was{" "}
Your next payment is{" "}
<strong>
<Money amount={props.subscription.lastPayment.amount} currency={props.subscription.lastPayment.currency} locale={fider.currentLocale} />
<Money amount={props.subscription.nextPayment.amount} currency={props.subscription.nextPayment.currency} locale={fider.currentLocale} />
</strong>{" "}
on{" "}
<strong>
<Moment locale={fider.currentLocale} format="date" date={props.subscription.lastPayment.date} />
<Moment locale={fider.currentLocale} format="date" date={props.subscription.nextPayment.date} />
</strong>
.
</p>
Expand All @@ -79,15 +112,22 @@ const ActiveSubscriptionInformation = (props: ManageBillingPageProps) => {
<a href="#" rel="noopener" className="text-link" onClick={open(props.subscription.cancelURL)}>
cancel
</a>{" "}
your subscription.
your subscription at any time.
</p>
<p>
To change your billing interval from monthly to yearly or vice-versa, please contact us at{" "}
<a className="text-link" href="mailto:[email protected]">
[email protected]
</a>
.
</p>
</VStack>
)
}

const CancelledSubscriptionInformation = (props: ManageBillingPageProps) => {
const fider = useFider()
const { price, openCheckoutUrl } = usePaddle({ ...props.paddle })
const paddle = usePaddle({ ...props.paddle })

const isExpired = new Date(props.subscriptionEndsAt) <= new Date()

Expand All @@ -111,14 +151,14 @@ const CancelledSubscriptionInformation = (props: ManageBillingPageProps) => {
. <br /> Resubscribe to avoid a service interruption.
</p>
)}
<SubscribeButton onClick={openCheckoutUrl} price={price} />
<SubscribePanel {...paddle} />
</VStack>
)
}

const TrialInformation = (props: ManageBillingPageProps) => {
const fider = useFider()
const { price, openCheckoutUrl } = usePaddle({ ...props.paddle })
const paddle = usePaddle({ ...props.paddle })

const isExpired = new Date(props.trialEndsAt) <= new Date()

Expand All @@ -144,7 +184,7 @@ const TrialInformation = (props: ManageBillingPageProps) => {
</p>
)}

<SubscribeButton onClick={openCheckoutUrl} price={price} />
<SubscribePanel {...paddle} />
</VStack>
)
}
Expand Down
4 changes: 2 additions & 2 deletions public/services/actions/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ interface CheckoutPageLink {
url: string
}

export const generateCheckoutLink = async (): Promise<Result<CheckoutPageLink>> => {
return await http.post("/_api/billing/checkout-link")
export const generateCheckoutLink = async (planId: string): Promise<Result<CheckoutPageLink>> => {
return await http.post("/_api/billing/checkout-link", { planId })
}
Loading

0 comments on commit 255c30e

Please sign in to comment.