Staging subscriptions (#5349)

* sandbox_plans.json -> sandbox_plans_v4.json

* add mix task and generate sandbox plans

* manually add sandbox_legacy_plans.json

* make all staging plans consistent with prod

* slight code style improvement

* add kb link
This commit is contained in:
RobertJoonas 2025-05-05 11:23:19 +02:00 committed by GitHub
parent b942081f30
commit 1de37a125c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 813 additions and 47 deletions

View File

@ -0,0 +1,203 @@
defmodule Mix.Tasks.CreatePaddleSandboxPlans do
@moduledoc """
Utility for creating Sandbox plans that are used on staging. The `filename`
argument should be the name of the JSON file containing the production plans.
E.g.: `plans_v4.json`.
Unfortunately, there's no API in Paddle that would allow "bulk creating"
plans - it has to be done through the UI. As a hack though, we can automate
the process by copying the curl request with the help of browser devtools.
Therefore, this Mix.Task **does not work out of the box** and the actual curl
command that it executes must be replaced by the developer. Here's how:
0) Obtain access to the Sandbox Paddle account and log in
1) Navigate to https://sandbox-vendors.paddle.com/subscriptions/plans
2) Click the "+ New Plan" button to open the form
3) Open browser devtools, fill in the required fields and submit the form
4) Find the POST request from the "Network" tab and copy it as cURL
5) Come back here and paste it into the `create_paddle_plan` function
6) Replace the params within the string with the real params (these should
be available in the function already)
Once the plans are created successfully, the task will also fetch the IDs
(i.e. paddle_plan_id's) and write the `sandbox_plans_v*.json` file (which is
basically the same set of production plans but with `monthly_product_id` and
`yearly_product_id` replaced with the sandbox ones).
"""
use Mix.Task
@requirements ["app.config"]
def run([filename]) do
{:ok, _} = Application.ensure_all_started(:telemetry)
Finch.start_link(name: MyFinch)
prod_plans =
Application.app_dir(:plausible, ["priv", filename])
|> File.read!()
|> JSON.decode!()
to_be_created =
prod_plans
|> put_prices()
|> Enum.flat_map(fn priced_plan ->
[
create_paddle_plan_attrs(priced_plan, "monthly"),
create_paddle_plan_attrs(priced_plan, "yearly")
]
end)
IO.puts("Fetching all sandbox plans before we get started...")
paddle_plans_before = fetch_all_sandbox_plans()
created =
Enum.filter(to_be_created, fn attrs ->
if Enum.any?(paddle_plans_before, &(&1["name"] == attrs.name)) do
IO.puts("⚠️ The plan #{attrs.name} already exists in Sandbox Paddle")
false
else
create_paddle_plan(attrs)
true
end
end)
paddle_plans_after =
if created != [] do
IO.puts("⏳ waiting 3s before fetching the newly created plans...")
Process.sleep(3000)
IO.puts("Fetching all sandbox plans after creation...")
fetch_all_sandbox_plans()
else
IO.puts("All plans have been created already.")
paddle_plans_before
end
file_path_to_write = Path.join("priv", "sandbox_" <> filename)
create_local_sandbox_plans_json_file(prod_plans, file_path_to_write, paddle_plans_after)
IO.puts("✅ All done! Wrote #{length(prod_plans)} new plans into #{file_path_to_write}!")
end
defp create_paddle_plan(%{name: name, price: price, type: type, interval_index: interval_index}) do
your_unique_token = "abc"
# Replace this curl command. You might be able to reuse
# the request body after replacing your unique token.
curl_command = """
... REPLACE ME
--data-raw '_token=#{your_unique_token}&plan-id=&default-curr=USD&tmpicon=false&name=#{name}&checkout_custom_message=&taxable_type=standard&interval=#{interval_index}&period=1&type=#{type}&trial_length=&price_USD=#{price}&active_EUR=on&price_EUR=#{price}&active_GBP=on&price_GBP=#{price}'
"""
case curl_quietly(curl_command) do
:ok ->
IO.puts("✅ Created #{name}")
{:error, reason} ->
IO.puts("❌ Halting. The plan #{name} could not be created. Error: #{reason}")
System.halt(1)
end
end
@paddle_interval_indexes %{"monthly" => 2, "yearly" => 5}
defp create_paddle_plan_attrs(plan_with_price, type) do
%{
name: plan_name(plan_with_price, type),
price: plan_with_price["#{type}_price"],
type: type,
interval_index: @paddle_interval_indexes[type]
}
end
defp fetch_all_sandbox_plans() do
paddle_config = Application.get_env(:plausible, :paddle)
url = "https://sandbox-vendors.paddle.com/api/2.0/subscription/plans"
body =
JSON.encode!(%{
vendor_id: paddle_config[:vendor_id],
vendor_auth_code: paddle_config[:vendor_auth_code]
})
headers = [
{"Content-type", "application/json"},
{"Accept", "application/json"}
]
request = Finch.build(:post, url, headers, body)
with {:ok, response} <- Finch.request(request, MyFinch),
{:ok, %{"success" => true, "response" => plans} = body} <- JSON.decode(response.body) do
IO.puts("✅ Successfully fetched #{body["count"]}/#{body["total"]} sandbox plans")
plans
else
error ->
IO.puts("❌ Failed to fetch plans from Paddle - #{inspect(error)}")
System.halt(1)
end
end
defp create_local_sandbox_plans_json_file(prod_plans, filepath, paddle_plans) do
sandbox_plans =
prod_plans
|> Enum.map(fn prod_plan ->
monthly_plan_name = plan_name(prod_plan, "monthly")
yearly_plan_name = plan_name(prod_plan, "yearly")
%{"id" => sandbox_monthly_product_id} =
Enum.find(paddle_plans, &(&1["name"] == monthly_plan_name))
%{"id" => sandbox_yearly_product_id} =
Enum.find(paddle_plans, &(&1["name"] == yearly_plan_name))
Map.merge(prod_plan, %{
"monthly_product_id" => to_string(sandbox_monthly_product_id),
"yearly_product_id" => to_string(sandbox_yearly_product_id)
})
end)
File.write!(filepath, JSON.encode!(sandbox_plans))
end
defp put_prices(plans) do
prices =
Application.app_dir(:plausible, ["priv", "plan_prices.json"])
|> File.read!()
|> JSON.decode!()
plans
|> Enum.map(fn plan ->
Map.merge(plan, %{
"monthly_price" => prices[plan["monthly_product_id"]],
"yearly_price" => prices[plan["yearly_product_id"]]
})
end)
end
defp plan_name(plan, type) do
generation = "v#{plan["generation"]}"
kind = plan["kind"]
volume = plan["monthly_pageview_limit"] |> PlausibleWeb.StatsView.large_number_format()
[generation, type, kind, volume] |> Enum.join("_")
end
defp curl_quietly(cmd) do
cmd = String.replace(cmd, "curl", ~s|curl -s -o /dev/null -w "%{http_code}"|)
case System.cmd("sh", ["-c", cmd], stderr_to_stdout: true) do
{"302", 0} ->
:ok
{http_status, 0} ->
{:error, "unexpected HTTP response status (#{http_status}). Expected 302."}
{_, exit_code} ->
{:error, "curl command exited with exit code #{exit_code}"}
end
end
end

View File

@ -4,27 +4,34 @@ defmodule Plausible.Billing.Plans do
alias Plausible.Billing.{Subscription, Plan, EnterprisePlan}
alias Plausible.Teams
for f <- [
:legacy_plans,
:plans_v1,
:plans_v2,
:plans_v3,
:plans_v4,
:sandbox_plans
] do
path = Application.app_dir(:plausible, ["priv", "#{f}.json"])
@generations [:legacy_plans, :plans_v1, :plans_v2, :plans_v3, :plans_v4]
for group <- Enum.flat_map(@generations, &[&1, :"sandbox_#{&1}"]) do
path = Application.app_dir(:plausible, ["priv", "#{group}.json"])
plans_list =
for attrs <- path |> File.read!() |> Jason.decode!() do
%Plan{} |> Plan.changeset(attrs) |> Ecto.Changeset.apply_action!(nil)
end
Module.put_attribute(__MODULE__, f, plans_list)
Module.put_attribute(__MODULE__, group, plans_list)
# https://hexdocs.pm/elixir/1.15/Module.html#module-external_resource
Module.put_attribute(__MODULE__, :external_resource, path)
end
# Generate functions returning a specific generation of plans depending on
# the app environment
for fn_name <- @generations do
defp unquote(fn_name)() do
if Application.get_env(:plausible, :environment) == "staging" do
unquote(Macro.escape(Module.get_attribute(__MODULE__, :"sandbox_#{fn_name}")))
else
unquote(Macro.escape(Module.get_attribute(__MODULE__, fn_name)))
end
end
end
@spec growth_plans_for(Subscription.t()) :: [Plan.t()]
@doc """
Returns a list of growth plans available for the subscription to choose.
@ -36,14 +43,13 @@ defmodule Plausible.Billing.Plans do
owned_plan = get_regular_plan(subscription)
cond do
Application.get_env(:plausible, :environment) == "staging" -> @sandbox_plans
is_nil(owned_plan) -> @plans_v4
subscription && Subscriptions.expired?(subscription) -> @plans_v4
owned_plan.kind == :business -> @plans_v4
owned_plan.generation == 1 -> @plans_v1 |> drop_high_plans(owned_plan)
owned_plan.generation == 2 -> @plans_v2 |> drop_high_plans(owned_plan)
owned_plan.generation == 3 -> @plans_v3
owned_plan.generation == 4 -> @plans_v4
is_nil(owned_plan) -> plans_v4()
subscription && Subscriptions.expired?(subscription) -> plans_v4()
owned_plan.kind == :business -> plans_v4()
owned_plan.generation == 1 -> plans_v1() |> drop_high_plans(owned_plan)
owned_plan.generation == 2 -> plans_v2() |> drop_high_plans(owned_plan)
owned_plan.generation == 3 -> plans_v3()
owned_plan.generation == 4 -> plans_v4()
end
|> Enum.filter(&(&1.kind == :growth))
end
@ -52,10 +58,9 @@ defmodule Plausible.Billing.Plans do
owned_plan = get_regular_plan(subscription)
cond do
Application.get_env(:plausible, :environment) == "staging" -> @sandbox_plans
subscription && Subscriptions.expired?(subscription) -> @plans_v4
owned_plan && owned_plan.generation < 4 -> @plans_v3
true -> @plans_v4
subscription && Subscriptions.expired?(subscription) -> plans_v4()
owned_plan && owned_plan.generation < 4 -> plans_v3()
true -> plans_v4()
end
|> Enum.filter(&(&1.kind == :business))
end
@ -223,14 +228,6 @@ defmodule Plausible.Billing.Plans do
end
def all() do
@legacy_plans ++ @plans_v1 ++ @plans_v2 ++ @plans_v3 ++ @plans_v4 ++ sandbox_plans()
end
defp sandbox_plans() do
if Application.get_env(:plausible, :environment) == "staging" do
@sandbox_plans
else
[]
end
legacy_plans() ++ plans_v1() ++ plans_v2() ++ plans_v3() ++ plans_v4()
end
end

View File

@ -0,0 +1,22 @@
[
{
"kind":"growth",
"generation": 1,
"monthly_pageview_limit":1000000,
"yearly_product_id":"78110",
"monthly_product_id":"78111",
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","stats_api"]
},
{
"kind":"growth",
"generation": 1,
"monthly_pageview_limit":150000000,
"yearly_product_id":"78112",
"monthly_product_id":null,
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","stats_api"]
}
]

142
priv/sandbox_plans_v1.json Normal file
View File

@ -0,0 +1,142 @@
[
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 10000,
"monthly_product_id": "78008",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78009"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 100000,
"monthly_product_id": "78010",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78011"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 200000,
"monthly_product_id": "78012",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78013"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 500000,
"monthly_product_id": "78014",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78015"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 1000000,
"monthly_product_id": "78016",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78017"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 2000000,
"monthly_product_id": "78018",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78019"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 5000000,
"monthly_product_id": "78020",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78021"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 10000000,
"monthly_product_id": "78022",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78023"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 20000000,
"monthly_product_id": "78024",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78025"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 1,
"kind": "growth",
"monthly_pageview_limit": 50000000,
"monthly_product_id": "78026",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78027"
}
]

142
priv/sandbox_plans_v2.json Normal file
View File

@ -0,0 +1,142 @@
[
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 10000,
"monthly_product_id": "78053",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78054"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 100000,
"monthly_product_id": "78055",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78056"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 200000,
"monthly_product_id": "78057",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78058"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 500000,
"monthly_product_id": "78059",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78060"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 1000000,
"monthly_product_id": "78061",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78062"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 2000000,
"monthly_product_id": "78063",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78064"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 5000000,
"monthly_product_id": "78065",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78066"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 10000000,
"monthly_product_id": "78067",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78068"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 20000000,
"monthly_product_id": "78069",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78070"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 2,
"kind": "growth",
"monthly_pageview_limit": 50000000,
"monthly_product_id": "78071",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78072"
}
]

250
priv/sandbox_plans_v3.json Normal file
View File

@ -0,0 +1,250 @@
[
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 10000,
"monthly_product_id": "78073",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78074"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 100000,
"monthly_product_id": "78075",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78076"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 200000,
"monthly_product_id": "78077",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78078"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 500000,
"monthly_product_id": "78079",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78080"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 1000000,
"monthly_product_id": "78081",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78082"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 2000000,
"monthly_product_id": "78083",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78084"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 5000000,
"monthly_product_id": "78085",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78086"
},
{
"features": [
"goals",
"props",
"stats_api"
],
"generation": 3,
"kind": "growth",
"monthly_pageview_limit": 10000000,
"monthly_product_id": "78087",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78088"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 10000,
"monthly_product_id": "78089",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78090"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 100000,
"monthly_product_id": "78091",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78092"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 200000,
"monthly_product_id": "78093",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78094"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 500000,
"monthly_product_id": "78095",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78096"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 1000000,
"monthly_product_id": "78097",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78098"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 2000000,
"monthly_product_id": "78099",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78100"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 5000000,
"monthly_product_id": "78101",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78102"
},
{
"features": [
"goals",
"props",
"revenue_goals",
"funnels",
"stats_api",
"site_segments"
],
"generation": 3,
"kind": "business",
"monthly_pageview_limit": 10000000,
"monthly_product_id": "78107",
"site_limit": 50,
"team_member_limit": "unlimited",
"yearly_product_id": "78109"
}
]

View File

@ -1,33 +1,36 @@
[
{
"kind":"growth",
"generation":1,
"generation":4,
"monthly_pageview_limit":10000,
"monthly_product_id":"63842",
"yearly_product_id":"63859",
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","stats_api"]
"features":["goals"],
"data_retention_in_years": 3
},
{
"kind":"growth",
"generation":2,
"generation":4,
"monthly_pageview_limit":100000,
"monthly_product_id":"63843",
"yearly_product_id":"63860",
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","stats_api"]
"features":["goals"],
"data_retention_in_years": 3
},
{
"kind":"growth",
"generation":3,
"generation":4,
"monthly_pageview_limit":200000,
"monthly_product_id":"63844",
"yearly_product_id":"63861",
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","stats_api"]
"features":["goals"],
"data_retention_in_years": 3
},
{
"kind":"growth",
@ -86,33 +89,36 @@
},
{
"kind":"business",
"generation":3,
"generation":4,
"monthly_pageview_limit":10000,
"monthly_product_id":"63850",
"yearly_product_id":"63867",
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","revenue_goals","funnels","stats_api","site_segments"]
"team_member_limit":10,
"features":["goals","props","revenue_goals","funnels","stats_api","site_segments"],
"data_retention_in_years": 5
},
{
"kind":"business",
"generation":3,
"generation":4,
"monthly_pageview_limit":100000,
"monthly_product_id":"63851",
"yearly_product_id":"63868",
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","revenue_goals","funnels","stats_api","site_segments"]
"team_member_limit":10,
"features":["goals","props","revenue_goals","funnels","stats_api","site_segments"],
"data_retention_in_years": 5
},
{
"kind":"business",
"generation":3,
"generation":4,
"monthly_pageview_limit":200000,
"monthly_product_id":"63852",
"yearly_product_id":"63869",
"site_limit":50,
"team_member_limit":"unlimited",
"features":["goals","props","revenue_goals","funnels","stats_api","site_segments"]
"team_member_limit":10,
"features":["goals","props","revenue_goals","funnels","stats_api","site_segments"],
"data_retention_in_years": 5
},
{
"kind":"business",

View File

@ -16,7 +16,11 @@
<br />
This local implementation skips steps 1-3, redirecting you first to <b><code>/billing/upgrade-success</code></b>, and after a short artificial delay, to <b><code>/settings</code></b>, with the subscription created.
<br /><br /> The real Paddle integration can be tested in "Sandbox" mode on staging.
<br /><br />
Read more about dev and staging subscriptions in the <a
class="text-indigo-600"
href="https://kb.plausible.io/engineering/how-to/dev-and-staging-subscriptions"
>knowledge base</a>.
</div>
<div class="flex items-center justify-between mt-10">