Modify the checkout widget to support subscriptions. Add usage-based billing toggle to sharing settings.

This commit is contained in:
2026-09-30 19:26:21 +02:00
parent 4b9434ce0a
commit 7b2e4b4240
25 changed files with 1060 additions and 684 deletions

View File

@@ -4,14 +4,38 @@ import PickAmount from "./PickAmount.svelte";
import PickCountry from "./PickCountry.svelte";
import { payment_providers, type CheckoutState } from "./CheckoutLib";
import PickName from "./PickName.svelte";
import { get_misc_vat_rate, get_user } from "lib/NovaAPI";
import { get_misc_subscriptions, get_misc_vat_rate, get_user, subscription_periods } from "lib/NovaAPI";
import { countries } from "country-data-list";
import PickProvider from "./PickProvider.svelte";
import PickPlan from "./PickPlan.svelte";
let status: CheckoutState = $state({country: null, provider: null, amount: 0, vat: 0, name: ""})
let status: CheckoutState = $state({
subscription: undefined,
months: 1,
country: null,
provider: null,
amount: 0,
vat: 0,
name: "",
})
onMount(async () => {
try {
// The plan links on the home page point here with the plan in the URL
const params = new URLSearchParams(window.location.search)
if (params.get("plan") !== null) {
const plan = (await get_misc_subscriptions())
.find(p => p.id === params.get("plan"))
if (plan !== undefined) {
const months = Number(params.get("months"))
status.subscription = plan
status.months = months >= plan.min_months &&
subscription_periods.some(p => p.months === months) ?
months : plan.min_months
}
}
const user = await get_user()
// Get the country from the user profile
@@ -39,9 +63,18 @@ onMount(async () => {
})
</script>
<div class="highlight_border">
{#if status.country !== null}
<div class="highlight_border checkout">
{#if status.subscription !== undefined}
<div class="navbar">
<button onclick={() => status.subscription = undefined}>
{#if status.subscription === null}
Plan: Credit deposit only
{:else}
Plan: {status.subscription.name}, {status.months}
month{status.months === 1 ? "" : "s"}
{/if}
</button>
{#if status.country !== null}
<button onclick={() => status.country = null}>
Country:
@@ -71,7 +104,9 @@ onMount(async () => {
<hr/>
{/if}
{#if status.country === null}
{#if status.subscription === undefined}
<PickPlan bind:status/>
{:else if status.country === null}
<PickCountry bind:status/>
{:else if status.provider === null}
<PickProvider bind:status/>
@@ -88,9 +123,25 @@ onMount(async () => {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.5em;
gap: 0.25em;
}
.provider_icon {
height: 1.5em;
}
/* The widget has no padding on the sides, that way the separators between the
steps span its full width. All the children add the padding back, except the
separators and the plan list, which scrolls sideways */
.checkout {
padding-left: 0;
padding-right: 0;
}
.checkout > :global(*:not(hr, .horizontal_scroll)) {
padding-left: 0.5em;
padding-right: 0.5em;
}
/* Running text reads better left aligned. Titles and buttons stay centred */
.checkout :global(.text) {
text-align: initial;
}
</style>

View File

@@ -1,7 +1,11 @@
import type { countries } from "country-data-list";
import { get_endpoint } from "lib/NovaAPI";
import { get_endpoint, subscription_periods, type Subscription } from "lib/NovaAPI";
export type CheckoutState = {
// undefined means the user has not picked yet, null means they chose to
// only deposit credit without starting a subscription
subscription: Subscription | null | undefined
months: number
country: typeof countries.all[0]
provider: PaymentProvider
amount: number
@@ -9,6 +13,16 @@ export type CheckoutState = {
name: string
}
// The price of the chosen subscription period in euro. The deposit can't be
// lower than this
export const plan_price = (state: CheckoutState): number => {
if (!state.subscription) {
return 0
}
return state.subscription.monthly_fee * state.months *
subscription_periods.find(p => p.months === state.months).discount / 1e6
}
export type PaymentProvider = {
icon: string
name: string
@@ -77,6 +91,8 @@ export const payment_providers: PaymentProvider[] = [
]
export const checkout = async (state: CheckoutState) => {
// Whether the deposit covers the subscription is checked by the server,
// which knows how much credit is already on the account
if (state.amount < 10) {
alert("Amount needs to be at least €10")
return
@@ -94,6 +110,12 @@ export const checkout = async (state: CheckoutState) => {
form.set("name", state.name)
}
// The server activates the subscription when the invoice is paid
if (state.subscription) {
form.set("subscription", state.subscription.id)
form.set("subscription_months", String(state.months))
}
try {
const resp = await fetch(
get_endpoint() + "/user/invoice",

View File

@@ -1,31 +1,129 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import Euro from "util/Euro.svelte";
import { checkout, type CheckoutState } from "./CheckoutLib";
import { checkout, plan_price, type CheckoutState } from "./CheckoutLib";
import { loading_finish, loading_start } from "lib/Loading";
import { days_per_month, put_user, subscription_periods, type SubscriptionPeriod } from "lib/NovaAPI";
import { user } from "lib/UserStore";
import { formatDate } from "util/Formatting";
let { status = $bindable() }: {
status: CheckoutState;
} = $props();
// The credit already on the account counts towards the subscription, so the
// deposit only has to cover the rest of it
let credit = $derived($user === null ? 0 : $user.balance_micro_eur / 1e6)
let missing = $derived(Math.max(0, plan_price(status) - credit))
// Extending the plan the user is already on is always a credit deposit, there
// is nothing to activate
let extending = $derived(
status.subscription !== null &&
$user !== null &&
status.subscription.id === $user.subscription.id &&
status.months === $user.subscription_months
)
// Monthly price of the data stored above the chosen plan's limit. The server
// bills this on top of the subscription. It is left out when extending, the
// user is already paying it
let excess_storage = $derived(
status.subscription === null || $user === null || extending ? 0 :
Math.max(0, $user.storage_space_used - status.subscription.storage_limit) *
status.subscription.price_per_tb_storage / 1e12,
)
// The account credit already pays for the whole subscription period, so there
// is nothing to check out. The user can activate the plan right away. Covered
// is false when the current plan is being extended because otherwise the amount
// picker would be hidden
let covered = $derived(
status.subscription !== null &&
missing === 0 &&
!extending
)
// Estimate of how long the credit lasts at the daily rate of the plan. Charges
// for usage outside of the plan are not included, so the real date comes sooner
let daily_price = $derived(status.subscription === null ? 0 :
status.subscription.monthly_fee * (
subscription_periods.find(p => p.months === status.months)?.discount ?? 1
) / days_per_month / 1e6,
)
let runs_until = $derived.by(() => {
if (daily_price <= 0) {
return null
}
const date = new Date()
date.setDate(date.getDate() + Math.floor(
(credit + (covered ? 0 : amount)) / daily_price,
))
return formatDate(date, false, false, false)
})
// We can't process payments below €10
let minimum = $derived(Math.max(10, Math.ceil(missing)))
const amounts = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000]
let amount = $state(20)
let amount = $state(10)
// Picking a longer period can raise the minimum above the chosen amount. We
// never lower it again, the user may have typed a custom amount
$effect(() => {
if (amount < minimum) {
amount = minimum
}
})
let periods = $derived(status.subscription === null ? [] : subscription_periods.filter(
p => p.months >= status.subscription.min_months,
))
// Price of a whole period, and what it would cost without the discount
const period_price = (months: number, discount: number) =>
status.subscription.monthly_fee * months * discount
const period_saving = (months: number, discount: number) =>
status.subscription.monthly_fee * months * (1 - discount)
const switch_period = (period: SubscriptionPeriod) => {
status.months = period.months
amount = minimum
}
const submit = async (e: SubmitEvent) => {
e.preventDefault()
status.amount = amount
loading_start()
try {
if (covered) {
// Nothing to pay, so we activate the subscription directly instead
// of sending the user to a payment provider
await put_user({
subscription: status.subscription.id,
subscription_months: status.months,
})
window.location.href = "/user/subscription"
return
}
status.amount = amount
await checkout(status)
}finally {
} catch (err) {
alert("Failed to activate subscription: "+(err?.message ?? err))
} finally {
loading_finish()
}
}
</script>
{#if status.provider.crypto === true}
<p style="text-align: initial;" class="highlight_blue">
{#if excess_storage > 0}
<p class="highlight_yellow text">
Your current storage usage exceeds the plan's limit. After activating
this subscription plan you will be charged <Euro amount={excess_storage}/>
per month on top of the subscription for excess storage usage.
</p>
{/if}
{#if status.provider.crypto === true && !covered}
<p class="highlight_blue text">
When paying with cryptocurrencies it is important that you pay
the <b>exact amount</b> stated on the order. If you pay too
little, the order fails. If you pay too much then the remaining
@@ -36,42 +134,187 @@ const submit = async (e: SubmitEvent) => {
</p>
{/if}
<form class="amount_grid" onsubmit={submit}>
<div class="span3">Please choose an amount</div>
{#each amounts as a}
<button
onclick={preventDefault(() => amount = a)}
class="amount_button"
class:button_highlight={amount === a}
>
€ {a}
</button>
{/each}
<div class="text">
{#if covered}
<div class="span3 custom_amount">
<div>Custom amount €</div>
<input type="number" bind:value={amount} min="10"/>
</div>
Subscription {status.subscription.name} costs
<Euro amount={plan_price(status)*1e6}/>
for {status.months} month{status.months === 1 ? "" : "s"},
which your account credit of <Euro amount={credit*1e6}/> already
covers. You don't have to pay anything now.
<div class="span2" style="text-align: initial;">
Total including VAT:
<Euro amount={(amount*1e6) + (amount*1e6)*(status.vat/100)}/>
</div>
{:else if status.subscription !== null}
Subscription {status.subscription.name} costs
<Euro amount={plan_price(status)*1e6}/>
for {status.months} month{status.months === 1 ? "" : "s"}.
{#if credit > 0}
Your account credit of <Euro amount={credit*1e6}/> is subtracted
from that, so you need to deposit at least
<Euro amount={missing*1e6}/> to activate the plan.
{/if}
You can deposit more, the remainder will extend the runtime of your
subscription plan.
{:else}
Please choose an amount
{/if}
</div>
<button type="submit" class="button_highlight">
<i class="icon">paid</i> Checkout
</button>
</form>
<hr/>
<p style="text-align: initial;">
This Nova premium plan costs €1 per month, but due to
processing fees we can't accept payments less than €10. So your
deposit will give roughly 10 months of premium service depending on
usage. You can track your spending on the <a
href="/user/prepaid/transactions">transactions page</a>.
<form onsubmit={submit}>
{#if covered}
<button type="submit" class="button_highlight" style="flex-direction: column;">
<i class="icon">shopping_cart</i>
Activate plan {status.subscription.name}
({status.months} month{status.months === 1 ? "" : "s"})
</button>
{:else}
<div class="amount_grid">
{#each amounts as a}
<button
onclick={(e: MouseEvent) => {e.preventDefault(); amount = a}}
class="amount_button"
class:button_highlight={amount === a}
disabled={a < minimum}
>
€ {a}
</button>
{/each}
<div class="span3 custom_amount">
<div>Custom amount €</div>
<input type="number" bind:value={amount} min={minimum}/>
</div>
<div class="span2 text">
VAT:
<Euro amount={(amount*1e6)*(status.vat/100)}/>,
total:
<Euro amount={(amount*1e6) + (amount*1e6)*(status.vat/100)}/>
</div>
<button type="submit" class="button_highlight">
<i class="icon">paid</i> Checkout
</button>
</div>
{/if}
</form>
{#if runs_until !== null}
<hr/>
<div class="text">
{#if extending}
This credit deposit will extend your current subscription until
about <span class="date">{runs_until}</span>, assuming usage within
the subscription plan's limits.
{:else}
This credit deposit will keep the {status.subscription.name} plan
running until about <span class="date">{runs_until}</span>, assuming
usage within the plan's limits.
{/if}
</div>
{/if}
<hr/>
{#if status.subscription !== null}
<div>Save money by paying ahead</div>
<table>
<thead>
<tr>
<th>Billing period</th>
<th>Price</th>
<th>Per day</th>
<th>You save</th>
<th></th>
</tr>
</thead>
<tbody>
{#each periods as p (p.months)}
<tr>
<td class="period">{p.name}</td>
<td data-label="Price">
<Euro amount={period_price(p.months, p.discount)}/>
</td>
<td data-label="Per day">
<Euro amount={status.subscription.monthly_fee*p.discount/days_per_month}/>
</td>
<td data-label="You save">
<Euro amount={period_saving(p.months, p.discount)}/>
({Math.round((1-p.discount)*100)}%)
</td>
<td>
{#if p.months === status.months}
<i class="icon">check</i> Selected
{:else}
<button onclick={() => switch_period(p)}>
<i class="icon">swap_horiz</i> Select
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<p class="text">
Subscriptions are charged from your account credit every day. Due to
processing fees we can't accept payments less than €10. You can track your
spending on the <a href="/user/subscription/transactions">transactions page</a>.
</p>
<style>
.date {
color: var(--highlight_color);
}
/* layout.css collapses the table borders, which makes the browser ignore any
padding on the table itself. The cells line the table up with the rest of
the widget instead */
th, td {
text-align: initial;
white-space: nowrap;
padding: 0 0.5em;
}
/* The table is too wide for narrow screens. There we stack each row into a
block and repeat the column headers in front of the cells */
@media (max-width: 600px) {
table, tbody, tr, td {
display: block;
}
thead {
display: none;
}
tbody tr {
border-top: 1px solid var(--separator);
padding: 0.5em 0;
}
td {
white-space: normal;
}
td[data-label]::before {
content: attr(data-label) ": ";
font-weight: bold;
}
.period {
font-size: 1.2em;
}
}
.amount_grid {
max-width: 500px;
gap: 4px;

View File

@@ -22,7 +22,7 @@ const submit = async (e: SubmitEvent) => {
</script>
<form class="name_form" onsubmit={submit}>
<div>
<div class="text">
This payment provider requires a name for checkout. Please enter your
full name below
</div>
@@ -34,11 +34,11 @@ const submit = async (e: SubmitEvent) => {
</button>
</form>
<hr/>
<p style="text-align: initial;">
<p class="text">
This Nova premium plan costs €1 per month, but due to processing fees we
can't accept payments less than €10. So your deposit will give roughly 10
months of premium service depending on usage. You can track your spending on
the <a href="/user/prepaid/transactions">transactions page</a>.
the <a href="/user/subscription/transactions">transactions page</a>.
</p>
<style>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import SubscriptionPlans from "./SubscriptionPlans.svelte";
import { put_user, type Subscription } from "lib/NovaAPI";
import { loading_finish, loading_start } from "lib/Loading";
import { user } from "lib/UserStore";
import SuccessMessage from "util/SuccessMessage.svelte";
import type { CheckoutState } from "./CheckoutLib";
import Euro from "util/Euro.svelte";
let { status = $bindable() }: {
status: CheckoutState;
} = $props();
let cancel_message: SuccessMessage = $state()
const pick = (plan: Subscription, months: number) => {
status.subscription = plan
status.months = months
}
// Extending keeps the plan the user is on, the deposit only adds credit to it
const extend = () => {
status.subscription = $user.subscription
status.months = $user.subscription_months || $user.subscription.min_months
}
const cancel = async () => {
if (!confirm(
"This cancels your subscription and moves your account back to the "+
"Free plan, which is limited to 50 GB of storage. Any credit stays "+
"on your account. Are you sure?"
)) {
return
}
loading_start()
try {
const plan = $user.subscription.name
await put_user({subscription: ""})
// put_user refreshes the user store, so the button below disappears on
// its own once the subscription is gone
cancel_message.set(true,
"Your "+plan+" subscription has been canceled. Your account is "+
"back on the Free plan")
} catch (err) {
cancel_message.set(false,
"Failed to cancel subscription: "+(err?.message ?? err))
} finally {
loading_finish()
}
}
</script>
<div>Please choose a subscription plan</div>
<SubscriptionPlans onpick={pick}/>
<div class="text">
Excess usage outside of the subscription plan will be billed at <Euro
amount={4e6}/> per TB per month for storage and <Euro amount={1e6}/> per TB
for data egress.
</div>
{#if $user !== null && $user.subscription.id !== ""}
<hr/>
<SuccessMessage bind:this={cancel_message}/>
<div class="options">
<button onclick={extend}>
<i class="icon">more_time</i>
Extend current plan (credit deposit)
</button>
<button onclick={cancel}>
<i class="icon">cancel</i>
Cancel current plan and return to Free
</button>
</div>
{/if}
<style>
.options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
}
.options > button {
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import Euro from "util/Euro.svelte";
import { formatDataVolume } from "util/Formatting";
import { get_misc_subscriptions, subscription_periods, type Subscription } from "lib/NovaAPI";
import { user } from "lib/UserStore";
// When onpick is set the period buttons call it instead of linking to the
// subscription page. The checkout widget uses this to pick a plan in place
let { onpick = undefined }: {
onpick?: (plan: Subscription, months: number) => void,
} = $props()
let plans: Subscription[] = $state([])
// Only the plan the user is on is highlighted. When they have no subscription
// there is nothing to compare against, so we highlight all of them
let active = $derived($user === null ? "" : $user.subscription.id)
get_misc_subscriptions().then(v => plans = v).catch(err => {
console.error("Failed to load subscription plans", err)
})
</script>
{#snippet price(plan: Subscription, period: typeof subscription_periods[0])}
<div class="bold">{period.label}</div>
<div>
<Euro amount={plan.monthly_fee*period.months*period.discount}/>
/ {period.months} month{period.months === 1 ? "" : "s"}
</div>
{/snippet}
<div class="horizontal_scroll">
<div class="feat_table">
{#each plans as plan (plan.id)}
<div class="plan" class:highlighted={active === "" || active === plan.id}>
<div>
<span class="title">{plan.name}</span>
<br/>
<Euro amount={plan.monthly_fee}/> / month
</div>
<div class="features">
<div>
<span class="bold">{formatDataVolume(plan.storage_limit, 0)}</span>
storage space
</div>
<div>
<span class="bold">{formatDataVolume(plan.monthly_transfer_cap, 0)}</span>
egress traffic per month
</div>
</div>
<div class="order">
{#each subscription_periods as period (period.months)}
{#if period.months < plan.min_months}
<span>{period.name} plan not available</span>
{:else if onpick !== undefined}
<button class="button" onclick={() => onpick(plan, period.months)}>
{@render price(plan, period)}
</button>
{:else}
<a href="/user/subscription?plan={plan.id}&months={period.months}" class="button">
{@render price(plan, period)}
</a>
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
<style>
.horizontal_scroll {
overflow-x: scroll;
overflow-y: hidden;
width: 100%;
}
/* max-content makes the row as wide as the cards, so the right padding ends
up after the last card instead of at the scroll container's edge */
.feat_table {
display: flex;
flex-direction: row;
width: max-content;
margin: auto;
gap: 8px;
padding: 8px;
}
.plan {
flex: 0 0 18em;
display: flex;
width: 18em;
flex-direction: column;
border: 1px solid var(--separator);
border-radius: 8px;
background-color: var(--body_color);
}
.plan.highlighted {
border-color: var(--highlight_color);
}
.plan > div {
flex: 1 1 0;
margin: 0.2em;
padding: 0.5em;
text-align: center;
word-wrap: break-word;
hyphens: auto;
}
.title {
font-size: 1.5em;
}
.plan > .order {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.plan > .order > .button {
flex-direction: column;
}
.plan > .order > .button > div {
width: 100%;
}
.bold {
font-weight: bold;
}
</style>