Skip to content

Billing UI changes #1552

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

Merged
merged 16 commits into from
Jan 13, 2025
2 changes: 1 addition & 1 deletion src/lib/components/billing/usageRates.svelte
Original file line number Diff line number Diff line change
@@ -67,7 +67,7 @@
following rates. Next billing period: {toLocaleDate(nextDate)}.
</p>
{/if}
<Table noStyles>
<Table noStyles noMargin>
<TableHeader>
<TableCellHead>Resource</TableCellHead>
<TableCellHead>Limit</TableCellHead>
7 changes: 5 additions & 2 deletions src/lib/components/collapsibleItem.svelte
Original file line number Diff line number Diff line change
@@ -7,12 +7,15 @@
export let noContent = false;
export let isInfo = false;
export let gap = 16;
export let style = null;
export let wrapperStyle = null;
</script>

<li class="collapsible-item" class:is-info={isInfo}>
{#if noContent}
<div class="collapsible-wrapper">
<div class={`collapsible-button u-gap-${gap}`}>
<div class="collapsible-wrapper" style={wrapperStyle}>
<div class={`collapsible-button u-gap-${gap}`} {style}>
<slot />
</div>
</div>
Original file line number Diff line number Diff line change
@@ -17,7 +17,6 @@
import { confirmPayment } from '$lib/stores/stripe';
import { sdk } from '$lib/stores/sdk';
import { toLocaleDate } from '$lib/helpers/date';
import { BillingPlan } from '$lib/constants';
import RetryPaymentModal from './retryPaymentModal.svelte';
import { selectedInvoice, showRetryModal } from './store';
import { Button } from '$lib/elements/forms';
@@ -130,9 +129,7 @@
<BillingAddress billingAddress={data?.billingAddress} />
<TaxId />
<BudgetCap />
{#if $organization?.billingPlan !== BillingPlan.FREE && !!$organization?.billingBudget}
<BudgetAlert />
{/if}
<BudgetAlert />
<AvailableCredit />
</Container>

Original file line number Diff line number Diff line change
@@ -90,7 +90,9 @@
</script>

<CardGrid hideFooter={$organization?.billingPlan !== BillingPlan.FREE}>
<Heading tag="h2" size="6">Available credit</Heading>
<Heading tag="h2" size="6">
{$organization?.billingPlan === BillingPlan.FREE ? 'Credits' : 'Available credit'}
</Heading>

<p class="text">Appwrite credit will automatically be applied to your next invoice.</p>
<svelte:fragment slot="aside">
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid, Heading } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Alert, CardGrid, Heading } from '$lib/components';
import { BillingPlan, Dependencies } from '$lib/constants';
import { tierToPlan, upgradeURL } from '$lib/stores/billing';
import { Button, Form, FormList, InputSelectSearch } from '$lib/elements/forms';
import {
Table,
@@ -15,7 +16,7 @@
} from '$lib/elements/table';
import { symmetricDifference } from '$lib/helpers/array';
import { addNotification } from '$lib/stores/notifications';
import { organization } from '$lib/stores/organization';
import { currentPlan, organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
@@ -39,7 +40,7 @@
if (alerts.some((alert) => alert === selectedAlert)) {
return;
}
if (alerts.length <= 2) {
if (alerts.length <= 3) {
alerts = [...alerts, selectedAlert ? selectedAlert : parseInt(search)];
search = '';
selectedAlert = null;
@@ -59,7 +60,7 @@
addNotification({
type: 'success',
isHtml: true,
message: `<span>A budget alert has been added to <b>${$organization.name}</b></span>`
message: `<span> ${alerts.length === 0 ? 'Budget alerts removed from' : alerts.length > 1 ? `Budget alerts added to` : 'A budget alert has been added to'} <b>${$organization.name}</b> </span>`
});
trackEvent(Submit.BudgetAlertsUpdate, {
alerts
@@ -78,65 +79,99 @@

<Form onSubmit={updateBudget}>
<CardGrid>
<Heading tag="h2" size="6">Budget alerts</Heading>
<Heading tag="h2" size="6">Billing alerts</Heading>

<p class="text">
Get notified by email when your organization reaches or exceeds a percent of your
specified budget cap. You can set a maximum of 3 alerts.
{#if !$currentPlan.budgeting}
Get notified by email when your organization meets a percentage of your budget cap. <b
>{tierToPlan($organization.billingPlan).name} organizations will receive one notification
at 75% resource usage.</b>
{:else}
Get notified by email when your organization meets or exceeds a percentage of your
specified billing alert(s).
{/if}
</p>
<svelte:fragment slot="aside">
<FormList>
<div class="u-flex u-gap-16">
<InputSelectSearch
label="Percentage (%) of budget cap"
placeholder="Select a percentage"
id="alerts"
{options}
bind:search
bind:value={selectedAlert}
on:select={() => (search = selectedAlert.toString())} />
<div style="align-self: flex-end">
<Button
secondary
disabled={alerts.length > 2 || (!search && !selectedAlert)}
on:click={addAlert}>
Add alert
</Button>
{#if !$currentPlan.budgeting}
<Alert type="info">
<svelte:fragment slot="title"
>Billing alerts are a Pro plan feature
</svelte:fragment>
Upgrade to a Pro plan to manage when you receive billing alerts for your organization.
</Alert>
{:else}
<FormList>
<Alert type="info">
You can set a maximum of 4 billing alerts per organization.
</Alert>

<div class="u-flex u-gap-16">
<InputSelectSearch
label="Percentage (%) of budget cap"
placeholder="Select a percentage"
id="alerts"
{options}
bind:search
interactiveOutput
bind:value={selectedAlert}
on:select={() => (search = selectedAlert.toString())} />
<div style="align-self: flex-end">
<Button
secondary
disabled={alerts.length > 3 || (!search && !selectedAlert)}
on:click={addAlert}>
Add alert
</Button>
</div>
</div>
</div>
</FormList>
</FormList>

{#if alerts.length}
<Table noMargin noStyles transparent>
<TableHeader>
<TableCellHead>Alert at budget cap %</TableCellHead>
<TableCellHead width={30} />
</TableHeader>
<TableBody>
{#each alerts.sort() as alert}
<TableRow>
<TableCellText title="Percentage">
{alert}%
</TableCellText>
<TableCell>
<Button
text
round
ariaLabel="remove alert"
on:click={() =>
(alerts = alerts.filter((a) => a !== alert))}>
<span class="icon-x" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{#if alerts.length}
<Table noMargin noStyles transparent>
<TableHeader>
<TableCellHead>Alert at budget cap %</TableCellHead>
<TableCellHead width={30} />
</TableHeader>
<TableBody>
{#each alerts.sort() as alert}
<TableRow>
<TableCellText title="Percentage">
{alert}%
</TableCellText>
<TableCell>
<Button
text
round
ariaLabel="remove alert"
on:click={() =>
(alerts = alerts.filter((a) => a !== alert))}>
<span class="icon-x" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{/if}
{/if}
</svelte:fragment>

<svelte:fragment slot="actions">
<Button disabled={isButtonDisabled} submit>Update</Button>
{#if $organization?.billingPlan === BillingPlan.FREE || $organization?.billingPlan === BillingPlan.GITHUB_EDUCATION}
<Button
secondary
href={$upgradeURL}
on:click={() => {
trackEvent('click_organization_upgrade', {
from: 'button',
source: 'billing_alerts_card'
});
}}
>Upgrade to Pro
</Button>
{:else}
<Button disabled={isButtonDisabled} submit>Update</Button>
{/if}
</svelte:fragment>
</CardGrid>
</Form>
Original file line number Diff line number Diff line change
@@ -53,10 +53,8 @@
<Heading tag="h2" size="6">Budget cap</Heading>

<p class="text">
Restrict your resource usage by setting a budget cap. <button
on:click={() => ($showUsageRatesModal = true)}
type="button"
class="link">Learn more about usage rates.</button>
Restrict your resource usage by setting a budget cap. Cap usage is reset at the
beginning of each billing cycle.
</p>
<svelte:fragment slot="aside">
{#if !$currentPlan.budgeting}
@@ -76,8 +74,12 @@
<FormList>
<InputSwitch id="cap-active" label="Enable budget cap" bind:value={capActive}>
<svelte:fragment slot="description">
Budget cap limits do not include the base amount of your plan. Cap usage
is reset at the beginning of each billing cycle.
Budget cap limits do not include the base amount of your plan. <button
class="link"
type="button"
on:click={() => ($showUsageRatesModal = true)}
>Learn more about usage rates.
</button>
</svelte:fragment>
</InputSwitch>
{#if capActive}
@@ -102,7 +104,9 @@
from: 'button',
source: 'billing_budget_cap'
});
}}>Upgrade to Pro</Button>
}}
>Upgrade to Pro
</Button>
{:else}
<Button disabled={$organization?.billingBudget === budget} submit>Update</Button>
{/if}
Original file line number Diff line number Diff line change
@@ -29,6 +29,7 @@
import { trackEvent } from '$lib/actions/analytics';
import { selectedInvoice, showRetryModal } from './store';
import { organization } from '$lib/stores/organization';
import { BillingPlan } from '$lib/constants';
let showDropdown = [];
let showFailedError = false;
@@ -64,135 +65,144 @@
}
</script>

<CardGrid>
<Heading tag="h2" size="6">Payment history</Heading>
{#if $organization?.billingPlan === BillingPlan.FREE && invoiceList.total > 0}
<CardGrid>
<Heading tag="h2" size="6">Payment history</Heading>

<p class="text">
Transaction history for this organization. Download invoices for more details about your
payments.
</p>
<svelte:fragment slot="aside">
{#if invoiceList.total > 0}
<TableScroll noMargin transparent noStyles>
<TableHeader>
<TableCellHead width={100}>Due Date</TableCellHead>
<TableCellHead width={80}>Status</TableCellHead>
<TableCellHead width={100}>Amount Due</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
{#each invoiceList?.invoices as invoice, i}
{@const status = invoice.status}
<TableRow>
<TableCellText title="date">
{toLocaleDate(invoice.dueAt)}
</TableCellText>
<TableCell title="status">
{#if invoice?.lastError}
<DropList bind:show={showFailedError}>
<p class="text">
Transaction history for this organization. Download invoices for more details about your
payments.
</p>
<svelte:fragment slot="aside">
{#if invoiceList.total > 0}
<TableScroll noMargin transparent noStyles>
<TableHeader>
<TableCellHead width={100}>Due Date</TableCellHead>
<TableCellHead width={80}>Status</TableCellHead>
<TableCellHead width={100}>Amount Due</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
{#each invoiceList?.invoices as invoice, i}
{@const status = invoice.status}
<TableRow>
<TableCellText title="date">
{toLocaleDate(invoice.dueAt)}
</TableCellText>
<TableCell title="status">
{#if invoice?.lastError}
<DropList bind:show={showFailedError}>
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' ||
status === 'succeeded'}
warning={status === 'pending'}
on:click={() => (showFailedError = true)}
button>
{status === 'requires_authentication'
? 'failed'
: status}
</Pill>
<svelte:fragment slot="list">
<li>
The scheduled payment has failed.
<Button
link
on:click={() => {
retryPayment(invoice);
showFailedError = false;
}}
>Try again
</Button>
.
</li>
</svelte:fragment>
</DropList>
{:else}
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}
on:click={() => (showFailedError = true)}
button>
warning={status === 'pending'}>
{status === 'requires_authentication'
? 'failed'
: status}
</Pill>
{/if}
</TableCell>
<TableCellText title="due">
{formatCurrency(invoice.grossAmount)}
</TableCellText>
<TableCell showOverflow right>
<DropList
bind:show={showDropdown[i]}
placement="bottom-start"
noArrow>
<Button
round
text
noMargin
ariaLabel="More options"
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<li>
The scheduled payment has failed. <Button
link
<DropListLink
icon="external-link"
external
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/view`}
on:click={() =>
(showDropdown[i] = !showDropdown[i])}
event="view_invoice">
View invoice
</DropListLink>
<DropListLink
icon="download"
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/download`}
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}
event="download_invoice">
Download PDF
</DropListLink>
{#if status === 'overdue' || status === 'failed'}
<DropListItem
icon="refresh"
on:click={() => {
retryPayment(invoice);
showFailedError = false;
}}>Try again</Button
>.
</li>
showDropdown[i] = !showDropdown[i];
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</DropListItem>
{/if}
</svelte:fragment>
</DropList>
{:else}
<Pill
danger={status === 'overdue' ||
status === 'failed' ||
status === 'requires_authentication'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}>
{status === 'requires_authentication' ? 'failed' : status}
</Pill>
{/if}
</TableCell>
<TableCellText title="due">
{formatCurrency(invoice.grossAmount)}
</TableCellText>
<TableCell showOverflow right>
<DropList
bind:show={showDropdown[i]}
placement="bottom-start"
noArrow>
<Button
round
text
noMargin
ariaLabel="More options"
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
<DropListLink
icon="external-link"
external
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/view`}
on:click={() => (showDropdown[i] = !showDropdown[i])}
event="view_invoice">
View invoice
</DropListLink>
<DropListLink
icon="download"
href={`${endpoint}/organizations/${$page.params.organization}/invoices/${invoice.$id}/download`}
on:click={() => {
showDropdown[i] = !showDropdown[i];
}}
event="download_invoice">
Download PDF
</DropListLink>
{#if status === 'overdue' || status === 'failed'}
<DropListItem
icon="refresh"
on:click={() => {
retryPayment(invoice);
showDropdown[i] = !showDropdown[i];
trackEvent(`click_retry_payment`, {
from: 'button',
source: 'billing_invoice_menu'
});
}}>
Retry payment
</DropListItem>
{/if}
</svelte:fragment>
</DropList>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {invoiceList?.total ?? 0}</p>
<PaginationInline {limit} bind:offset sum={invoiceList?.total ?? 0} hidePages />
</div>
{:else}
<EmptySearch hidePagination>
<p class="text u-text-center">
You have no payment history. After you receive your first invoice, you'll see it
here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
</CardGrid>
</TableCell>
</TableRow>
{/each}
</TableBody>
</TableScroll>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {invoiceList?.total ?? 0}</p>
<PaginationInline {limit} bind:offset sum={invoiceList?.total ?? 0} hidePages />
</div>
{:else}
<EmptySearch hidePagination>
<p class="text u-text-center">
You have no payment history. After you receive your first invoice, you'll
see it here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
</CardGrid>
{/if}
Original file line number Diff line number Diff line change
@@ -99,7 +99,6 @@
<p class="text">View or update your organization payment methods here.</p>
<svelte:fragment slot="aside">
<div class="u-flex u-flex-vertical u-gap-8">
<h4 class="u-bold body-text-2">Default</h4>
{#if $organization?.paymentMethodId}
<CreditCardInfo isBox paymentMethod={defaultPaymentMethod}>
<DropList bind:show={showDropdown} placement="bottom-start" noArrow>
@@ -196,64 +195,65 @@
</article>
{/if}
</div>
<div class="u-flex u-flex-vertical u-gap-8">
<h4 class="u-bold body-text-2">Backup</h4>
{#if $organization?.backupPaymentMethodId}
<CreditCardInfo isBox paymentMethod={backupPaymentMethod}>
<DropList bind:show={showDropdownBackup} placement="bottom-start" noArrow>
<Button
round
text
ariaLabel="More options"
on:click={() => {
showDropdownBackup = !showDropdownBackup;
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
{#if backupPaymentMethod.userId === $user.$id}
{#if $organization?.billingPlan !== BillingPlan.FREE && $organization?.billingPlan !== BillingPlan.GITHUB_EDUCATION}
<div class="u-flex u-flex-vertical u-gap-8">
{#if $organization?.backupPaymentMethodId}
<h4 class="u-bold body-text-2">Backup</h4>
<CreditCardInfo isBox paymentMethod={backupPaymentMethod}>
<DropList bind:show={showDropdownBackup} placement="bottom-start" noArrow>
<Button
round
text
ariaLabel="More options"
on:click={() => {
showDropdownBackup = !showDropdownBackup;
}}>
<span class="icon-dots-horizontal" aria-hidden="true" />
</Button>
<svelte:fragment slot="list">
{#if backupPaymentMethod.userId === $user.$id}
<DropListItem
icon="pencil"
on:click={() => {
showEdit = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Edit
</DropListItem>
{/if}
<DropListItem
icon="pencil"
icon="switch-horizontal"
on:click={() => {
showEdit = true;
showReplace = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Edit
Replace
</DropListItem>
{/if}
<DropListItem
icon="switch-horizontal"
on:click={() => {
showReplace = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Replace
</DropListItem>
<DropListItem
icon="trash"
on:click={() => {
showDelete = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</CreditCardInfo>
{:else}
{@const filteredPaymentMethods = $paymentMethods.paymentMethods.filter(
(o) => !!o.last4 && o.$id !== $organization?.paymentMethodId
)}
<article class="card u-grid u-cross-center u-width-full-line dashed">
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<div class="common-section">
<DropList bind:show={showDropdownBackup} placement="bottom-start">
<DropListItem
icon="trash"
on:click={() => {
showDelete = true;
isSelectedBackup = true;
showDropdownBackup = false;
}}>
Delete
</DropListItem>
</svelte:fragment>
</DropList>
</CreditCardInfo>
{:else}
{@const filteredPaymentMethods = $paymentMethods.paymentMethods.filter(
(o) => !!o.last4 && o.$id !== $organization?.paymentMethodId
)}
<div
class="u-bold body-text-2 u-flex u-cross-center u-gap-8 u-padding-block-start-8">
<DropList bind:show={showDropdownBackup} placement="bottom-start">
<div class="u-flex u-gap-8 u-cross-center">
<Button
secondary
round
text
noMargin
on:click={() => {
if (filteredPaymentMethods.length) {
showDropdownBackup = !showDropdownBackup;
@@ -262,46 +262,44 @@
showPayment = true;
}
}}>
<i class="icon-plus" />
<span class="icon-plus" />
<span class="text"> Add a backup payment method </span>
</Button>
<svelte:fragment slot="list">
{#if $paymentMethods.total}
{#each filteredPaymentMethods as paymentMethod}
<DropListItem
on:click={() => {
showDropdownBackup = true;
addBackupPaymentMethod(paymentMethod?.$id);
}}>
<span class="u-flex u-cross-center u-gap-8">
<p class="text">
Card ending in {paymentMethod.last4}
</p>
<CreditCardBrandImage
brand={paymentMethod?.brand} />
</span>
</DropListItem>
{/each}
{/if}
<DropListItem on:click={() => (showPayment = true)}>
Add new payment method
</DropListItem>
</svelte:fragment>
</DropList>
</div>
<div class="common-section u-flex u-cross-center u-gap-4">
<span class="text"> Add a backup payment method </span>
<span
class="icon-info"
style="font-size: var(--icon-size-small)"
use:tooltip={{
content:
'When your default payment method fails, a backup method will be used to pay your invoice automatically'
}} />
</div>
<span
class="icon-info u-cursor-pointer"
style="font-size: var(--icon-size-small)"
use:tooltip={{
content:
'When your default payment method fails, a backup method will be used to pay your invoice automatically'
}} />
</div>
<svelte:fragment slot="list">
{#if $paymentMethods.total}
{#each filteredPaymentMethods as paymentMethod}
<DropListItem
on:click={() => {
showDropdownBackup = true;
addBackupPaymentMethod(paymentMethod?.$id);
}}>
<span class="u-flex u-cross-center u-gap-8">
<p class="text">
Card ending in {paymentMethod.last4}
</p>
<CreditCardBrandImage
brand={paymentMethod?.brand} />
</span>
</DropListItem>
{/each}
{/if}
<DropListItem on:click={() => (showPayment = true)}>
Add new payment method
</DropListItem>
</svelte:fragment>
</DropList>
</div>
</article>
{/if}
</div>
{/if}
</div>
{/if}
</svelte:fragment>
</CardGrid>

Large diffs are not rendered by default.