Merge branch 'billing' of https://github.com/appwrite/console into fix-billing-usage

This commit is contained in:
Torsten Dittmann
2023-11-27 14:28:01 +01:00
26 changed files with 537 additions and 232 deletions
-1
View File
@@ -16,7 +16,6 @@
return;
};
export let title = '';
export let description = '';
let backdrop: HTMLDivElement;
+2
View File
@@ -15,6 +15,7 @@
export let external = false;
export let href: string = null;
export let fullWidth = false;
export let fullWidthMobile = false;
export let ariaLabel: string = null;
export let noMargin = false;
export let event: string = null;
@@ -47,6 +48,7 @@
text && 'is-text',
danger && 'is-danger',
fullWidth && 'is-full-width',
fullWidthMobile && 'is-full-width-mobile',
noMargin && 'u-padding-inline-0',
classes
]
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
export let title: string;
</script>
<section class="alert is-action is-action-and-top-sticky is-danger u-sep-block-end">
<div class="alert-grid">
<span class="icon-info" aria-hidden="true" />
<div class="alert-content">
{#if title || $$slots.title}
<h6 class="alert-title">
<slot name="title">
{title}
</slot>
</h6>
{/if}
<p class="alert-message">
<slot />
</p>
</div>
{#if $$slots.buttons}
<div class="alert-buttons u-flex u-gap-16 u-cross-child-center">
<slot name="buttons" />
</div>
{/if}
</div>
</section>
+1
View File
@@ -17,3 +17,4 @@ export { default as Activity } from './activity.svelte';
export { default as Progress } from './progress.svelte';
export { default as GridHeader } from './gridHeader.svelte';
export { default as ContainerHeader } from './containerHeader.svelte';
export { default as HeaderAlert } from './headerAlert.svelte';
+23 -2
View File
@@ -37,6 +37,7 @@ export type Invoice = {
to: string;
status: string;
dueAt: string;
clientSecret: string;
};
export type InvoiceList = {
@@ -44,6 +45,15 @@ export type InvoiceList = {
total: number;
};
export type Coupon = {
$id: string;
code: string;
credits: number;
expiration: string;
status: string; // 'active' | 'disabled' | 'expired'
validity: number;
};
export type Credit = {
/**
* Credit ID.
@@ -159,8 +169,19 @@ export type AggregationList = {
total: number;
};
export type AllowedRegions =
| 'eu-de'
| 'us-nyc'
| 'us-sfo'
| 'ap-in'
| 'eu-gb'
| 'eu-nl'
| 'ap-sg'
| 'ap-ca'
| 'ap-au';
export type Region = {
$id: string;
$id: AllowedRegions;
name: string;
disabled: boolean;
default: boolean;
@@ -571,7 +592,7 @@ export class Billing {
);
}
async getCoupon(couponId: string): Promise<Partial<Credit>> {
async getCoupon(couponId: string): Promise<Coupon> {
const path = `/console/coupons/${couponId}`;
const params = {
couponId
+32 -28
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { INTERVAL } from '$lib/constants';
import { Logs } from '$lib/layout';
import { HeaderAlert, Logs } from '$lib/layout';
import Footer from '$lib/layout/footer.svelte';
import Header from '$lib/layout/header.svelte';
import SideNavigation from '$lib/layout/navigation.svelte';
@@ -44,6 +44,7 @@
import ExcesLimitModal from './organization-[organization]/excesLimitModal.svelte';
import { showExcess } from './organization-[organization]/store';
import UsageRates from './wizard/cloudOrganization/usageRates.svelte';
import { Button } from '$lib/elements/forms';
function kebabToSentenceCase(str: string) {
return str
@@ -327,6 +328,7 @@
const payment = await sdk.forConsole.billing.getPaymentMethod(
$organization.paymentMethodId
);
if (!payment?.expiryYear) return;
const year = new Date().getFullYear();
const month = new Date().getMonth();
const expiredMessage = `The default payment method for <b>${$organization.name}</b> has expired`;
@@ -393,33 +395,35 @@
<svelte:fragment slot="alert">
{#if $failedInvoice && !$page.url.pathname.includes('/console/account')}
{@const daysPassed = diffDays(new Date($failedInvoice.dueAt), new Date())}
<section class="alert is-action is-action-and-top-sticky is-danger u-sep-block-end">
<div class="alert-grid">
<span class="icon-info" aria-hidden="true" />
<div class="alert-content">
<h6 class="alert-title">Your projects are at risk</h6>
<p class="alert-message">
{#if daysPassed > 30}
Your scheduled payment on <b
>{toLocaleDate($failedInvoice?.dueAt)}</b> failed. To resume write
access of your organization, please update your billing details.
{:else}
Your scheduled payment on <b
>{toLocaleDate($failedInvoice?.dueAt)}</b> failed. Access to paid
projects within this organization will be disabled if no action is taken
within 30 days.
{/if}
</p>
</div>
<div class="alert-buttons u-flex u-gap-16 u-cross-child-center">
<a
href={`${base}/console/organization-${$failedInvoice?.teamId}/billing#paymentMethods`}
class="button is-secondary is-full-width-mobile">
<span class="text">Update billing details</span>
</a>
</div>
</div>
</section>
<HeaderAlert title="Your projects are at risk">
<svelte:fragment>
{#if daysPassed > 30}
Your scheduled payment on <b>{toLocaleDate($failedInvoice?.dueAt)}</b> failed.
To resume write access of your organization, please update your billing details.
{:else}
Your scheduled payment on <b>{toLocaleDate($failedInvoice?.dueAt)}</b> failed.
Access to paid projects within this organization will be disabled if no action
is taken within 30 days.
{/if}
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button
href={`${base}/console/organization-${$failedInvoice?.teamId}/billing#paymentMethods`}
secondary
fullWidthMobile>
<span class="text">Update billing details</span>
</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
<!-- TODO: add variable from backend that indicates org is due to be deleted -->
{#if false}
<HeaderAlert title="Organization flagged for deletion">
<svelte:fragment>
All existing projects in the {$organization.name} organization have been paused.
This organization will be deleted once your upcoming invoice is processed successfully.
</svelte:fragment>
</HeaderAlert>
{/if}
</svelte:fragment>
<Header slot="header" />
@@ -14,7 +14,6 @@
changeOrganizationFinalAction,
changeOrganizationTier,
changeTierSteps,
currentBillingAddress,
isUpgrade
} from './wizard/cloudOrganizationChangeTier/store';
import { goto, invalidate } from '$app/navigation';
@@ -24,12 +23,13 @@
import { organization } from '$lib/stores/organization';
import { wizard } from '$lib/stores/wizard';
import { tierToPlan } from '$lib/stores/billing';
import deepEqual from 'deep-equal';
import { user } from '$lib/stores/user';
import { feedback } from '$lib/stores/feedback';
const dispatch = createEventDispatcher();
async function onFinish() {
await invalidate(Dependencies.FUNCTIONS);
await invalidate(Dependencies.ORGANIZATION);
}
async function changeTier() {
@@ -40,8 +40,13 @@
$changeOrganizationTier.billingPlan,
$changeOrganizationTier.paymentMethodId
);
feedback.submitFeedback(
'downgrade',
$changeOrganizationTier.feedbackMessage,
$user?.name ?? '',
$user.email
);
//TODO: send feedback
addNotification({
type: 'success',
isHtml: true,
@@ -75,10 +80,14 @@
);
//Add billing address
if (
if ($changeOrganizationTier.billingAddressId) {
await sdk.forConsole.billing.setBillingAddress(
org.$id,
$changeOrganizationTier.billingAddressId
);
} else if (
$changeOrganizationTier.billingAddress &&
$changeOrganizationTier.billingAddress.streetAddress &&
!deepEqual($changeOrganizationTier.billingAddress, $currentBillingAddress)
$changeOrganizationTier.billingAddress.streetAddress
) {
const response = await sdk.forConsole.billing.createAddress(
$changeOrganizationTier.billingAddress.country,
@@ -94,6 +103,14 @@
await sdk.forConsole.billing.setBillingAddress(org.$id, response.$id);
}
//Add coupon
if ($changeOrganizationTier.couponCode) {
await sdk.forConsole.billing.addCredit(
org.$id,
$changeOrganizationTier.couponCode
);
}
//Add budget
if ($changeOrganizationTier?.billingBudget) {
await sdk.forConsole.billing.updateBudget(
@@ -165,6 +182,7 @@
billingPlan: 'tier-1',
paymentMethodId: null,
collaborators: [],
billingAddressId: null,
billingAddress: {
$id: null,
streetAddress: null,
@@ -174,7 +192,8 @@
postalCode: null,
country: null
},
taxId: null
taxId: null,
feedbackMessage: null
};
});
@@ -33,7 +33,12 @@
$createOrganization.paymentMethodId
);
//Add billing address
if (
if ($createOrganization.billingAddressId) {
await sdk.forConsole.billing.setBillingAddress(
org.$id,
$createOrganization.billingAddressId
);
} else if (
$createOrganization.billingAddress &&
$createOrganization.billingAddress.streetAddress
) {
@@ -106,6 +111,7 @@
billingPlan: 'tier-1',
paymentMethodId: null,
collaborators: [],
billingAddressId: null,
billingAddress: {
$id: null,
streetAddress: null,
@@ -14,8 +14,10 @@
import type { PaymentMethodData } from '$lib/sdk/billing';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { isStripeInitialized, stripe } from '$lib/stores/stripe';
import { initializeStripe, isStripeInitialized, stripe } from '$lib/stores/stripe';
import { base } from '$app/paths';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
$: defaultPaymentMethod = $paymentMethods?.paymentMethods?.find(
(method: PaymentMethodData) => method.$id === $organization?.paymentMethodId
@@ -27,25 +29,58 @@
onMount(async () => {
if (
$isStripeInitialized &&
$page.url.searchParams.has('invoice') &&
$page.url.searchParams.has('type') &&
$page.url.searchParams.get('type') === 'confirmation'
($page.url.searchParams.has('invoice') &&
$page.url.searchParams.has('type') &&
$page.url.searchParams.get('type') === 'confirmation') ||
$page.url.searchParams.has('clientSecret')
) {
if (!$isStripeInitialized) {
await initializeStripe();
}
console.log('test');
console.log($page.url.searchParams.get('invoice'));
const { setupIntent, error } = await $stripe.confirmCardSetup(
'{SETUP_INTENT_CLIENT_SECRET}',
{
payment_method: {
id: $organization.paymentMethodId,
try {
const invoiceId = $page.url.searchParams.get('invoice');
const invoice = await sdk.forConsole.billing.getInvoice(
$page.params.organization,
invoiceId
);
const { setupIntent, error } = await $stripe.confirmCardSetup(
invoice.clientSecret,
{
payment_method: $organization.paymentMethodId,
return_url: `${base}/console/organization-${$organization.$id}/billing`
}
);
console.log(setupIntent);
if (error) {
console.log('Something went wrong');
}
);
if (error) {
console.log('Something went wrong');
if (setupIntent.status === 'succeeded') {
if (typeof setupIntent.payment_method === 'string') {
await sdk.forConsole.billing.setOrganizationPaymentMethod(
$page.params.organization,
setupIntent.payment_method
);
} else {
await sdk.forConsole.billing.setOrganizationPaymentMethod(
$page.params.organization,
setupIntent.payment_method.id
);
}
addNotification({
title: 'Success',
message: 'Your payment method has been updated',
type: 'success'
});
}
} catch (error) {
addNotification({
title: 'Error',
message:
'There was an error processing your payment, try again later. If the problem persists, please contact support.',
type: 'error'
});
}
}
});
@@ -82,8 +82,8 @@
<TableCell title="status">
<Pill
danger={status === 'overdue'}
success={status === 'paid'}
danger={status === 'overdue' || status === 'failed'}
success={status === 'paid' || status === 'succeeded'}
warning={status === 'pending'}>
{status}
</Pill>
@@ -22,12 +22,14 @@
onMount(async () => {
methods = await sdk.forConsole.billing.listPaymentMethods();
if (!$organization.paymentMethodId && !$organization.backupPaymentMethodId) {
selectedPaymentMethodId = methods?.total ? methods.paymentMethods[0].$id : null;
}
selectedPaymentMethodId = isBackup
? $organization.backupPaymentMethodId
: $organization.paymentMethodId;
});
// TODO: fix new payment method replacement
async function handleSubmit() {
try {
if (!selectedPaymentMethodId) {
@@ -167,7 +169,8 @@
secondary
submit
disabled={selectedPaymentMethodId ===
(isBackup ? $organization.backupPaymentMethodId : $organization.paymentMethodId)}
>Save</Button>
(isBackup ? $organization.backupPaymentMethodId : $organization.paymentMethodId)}>
Save
</Button>
</svelte:fragment>
</FakeModal>
@@ -33,6 +33,7 @@
name || undefined
);
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATION);
showCreate = false;
addNotification({
type: 'success',
@@ -30,14 +30,15 @@
});
function calculateExcess() {
const totBandwidth = usage?.bandwidth?.length > 0 ? usage.bandwidth[0].value : 0;
const totUsers = usage?.users?.length > 0 ? usage.users[0].value : 0;
excess = {
bandwidth:
usage?.bandwidth?.[0] > plan.bandwidth ? usage?.bandwidth?.[0] - plan.bandwidth : 0,
bandwidth: totBandwidth > plan.bandwidth ? totBandwidth - plan.bandwidth : 0,
storage:
usage?.storage[0] > sizeToBytes(plan.storage, 'GB')
? usage.storage[0] - plan.storage
: 0,
users: usage?.users?.[0] > plan.users ? usage?.users?.[0] - plan.users : 0,
users: totUsers > plan.users ? totUsers - plan.users : 0,
executions:
usage?.executions[0] > plan.executions ? usage.executions[0] - plan.executions : 0,
members: members.total > plan.members ? members.total - (plan.members || Infinity) : 0
@@ -56,17 +57,22 @@
</p>
<PlanExcess {excess} currentTier={$organization.billingPlan} />
<svelte:fragment slot="footer">
<Button
secondary
on:click={() => {
show = false;
goto(`/console/organization-${$organization.$id}/usage`);
}}>View usage</Button>
<div class="u-flex u-main-space-between u-width-full-line">
<Button text on:click={() => (show = false)}>Cancel</Button>
<div class="u-flex u-main-end u-gap-16">
<Button
secondary
on:click={() => {
show = false;
goto(`/console/organization-${$organization.$id}/usage`);
}}>View usage</Button>
<Button
on:click={() => {
show = false;
wizard.start(ChangeOrganizationTierCloud);
}}>Upgrade plan</Button>
<Button
on:click={() => {
show = false;
wizard.start(ChangeOrganizationTierCloud);
}}>Upgrade plan</Button>
</div>
</div>
</svelte:fragment>
</Modal>
@@ -1,10 +1,11 @@
import { PAGE_LIMIT } from '$lib/constants';
import { Dependencies, PAGE_LIMIT } from '$lib/constants';
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
import { sdk } from '$lib/stores/sdk';
import { Query } from '@appwrite.io/console';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url, params, route }) => {
export const load: PageLoad = async ({ url, params, route, depends }) => {
depends(Dependencies.ORGANIZATION);
const page = getPage(url);
const limit = getLimit(url, route, PAGE_LIMIT);
const offset = pageToOffset(page, limit);
@@ -8,7 +8,7 @@
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { onMount } from 'svelte';
import Delete from '../deleteOrganization.svelte';
import Delete from './deleteOrganization.svelte';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
let name: string;
@@ -0,0 +1,10 @@
import type { PageLoad } from './$types';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
export const load: PageLoad = async ({ depends }) => {
depends(Dependencies.ORGANIZATION);
return {
projects: await sdk.forConsole.projects.list()
};
};
@@ -8,8 +8,14 @@
import { base } from '$app/paths';
import { Dependencies } from '$lib/constants';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { projects } from '../store';
import { toLocaleDate } from '$lib/helpers/date';
export let showDelete = false;
let error: string = null;
//TODO: replace variable with value from method
const TEMPORARY_SET_FOR_DELETION = true;
async function deleteOrg() {
try {
@@ -26,12 +32,9 @@
goto(`${base}/console/onboarding`);
}
showDelete = false;
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.OrganizationDelete);
} catch (e) {
error = e.message;
trackError(e, Submit.OrganizationDelete);
}
}
</script>
@@ -40,13 +43,25 @@
title="Delete organization"
onSubmit={deleteOrg}
bind:show={showDelete}
bind:error
icon="exclamation"
state="warning"
headerDivider={false}>
<p data-private>
Are you sure you want to delete <b>{$organization.name}</b>? All projects ({$organization.total})
and data associated with this organization will be deleted. This action is irreversible.
</p>
{#if TEMPORARY_SET_FOR_DELETION}
<p data-private>
The organization <b>{$organization.name}</b> will be flagged for deletion.
</p>
<p data-private>
All existing projects will be paused and the organization will be deleted once your
upcoming invoice is processed on {toLocaleDate($organization.billingNextInvoiceDate)}.
</p>
{:else}
<p data-private>
Are you sure you want to delete <b>{$organization.name}</b>? All projects ({$projects.total})
and data associated with this organization will be deleted. This action is irreversible.
</p>
{/if}
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit>Delete</Button>
@@ -1,3 +1,6 @@
import { writable } from 'svelte/store';
import { page } from '$app/stores';
import type { Models } from '@appwrite.io/console';
import { derived, writable } from 'svelte/store';
export const showExcess = writable<boolean>(false);
export const projects = derived(page, ($page) => $page.data?.projects as Models.ProjectList);
@@ -5,12 +5,35 @@
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { createProject } from './store';
import type { RegionList } from '$lib/sdk/billing';
import type { Region, RegionList } from '$lib/sdk/billing';
import { addNotification } from '$lib/stores/notifications';
import type { Models } from '@appwrite.io/console';
let regions: RegionList;
let prefs: Models.Preferences;
onMount(async () => {
regions = await sdk.forConsole.billing.listRegions();
prefs = await sdk.forConsole.account.getPrefs();
});
async function notifyRegion(selectedRegion: Region) {
try {
let newPrefs = { ...prefs };
newPrefs.notifications = newPrefs.notifications ?? [];
newPrefs.notifications = [...newPrefs.notifications, selectedRegion.$id];
const response = await sdk.forConsole.account.updatePrefs(newPrefs);
prefs = response.prefs;
addNotification({
type: 'success',
isHtml: true,
message: `You will be notified when <b>${selectedRegion.name}</b> region is available`
});
} catch (error) {
console.log(error);
}
}
$: notifications = prefs?.notifications ?? [];
</script>
<WizardStep>
@@ -39,10 +62,17 @@
flag={region.flag}
name={region.name} />
<p class:u-opacity-50={region.disabled}>{region.name}</p>
<Pill button event="region_notify">
<span class="icon-bell" aria-hidden="true" />
<span class="text">Notify me</span>
</Pill>
{#if !notifications.includes(region.$id)}
<Pill
button
event="region_notify"
on:click={() => {
notifyRegion(region);
}}>
<span class="icon-bell" aria-hidden="true" />
<span class="text">Notify me</span>
</Pill>
{/if}
{:else}
<Flag
width={40}
+2 -1
View File
@@ -8,6 +8,7 @@
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { wizard } from '$lib/stores/wizard';
import { VARS } from '$lib/system';
onDestroy(() => {
$supportData = {
@@ -25,7 +26,7 @@
});
async function handleSubmit() {
const response = await fetch('https://growth.appwrite.io/v1/support', {
const response = await fetch(`https://${VARS.GROWTH_ENDPOINT}/v1/support`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -1,10 +1,10 @@
<script lang="ts">
import { FormItem, FormList, InputSelect, InputText } from '$lib/elements/forms';
import { FormItem, FormList, InputRadio, InputSelect, InputText } from '$lib/elements/forms';
import { WizardStep } from '$lib/layout';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { Alert } from '$lib/components';
import { createOrganization } from './store';
import type { AddressesList } from '$lib/sdk/billing';
let options = [
{
@@ -12,8 +12,11 @@
label: 'United States'
}
];
let addressList: AddressesList;
onMount(async () => {
addressList = await sdk.forConsole.billing.listAddresses();
$createOrganization.billingAddressId = addressList.billingAddresses?.[0]?.$id ?? null;
const countryList = await sdk.forProject.locale.listCountries();
options = countryList.countries.map((country) => {
return {
@@ -28,58 +31,97 @@
<svelte:fragment slot="title">Billing address</svelte:fragment>
<svelte:fragment slot="subtitle">Add a billing address for your organization.</svelte:fragment>
<Alert type="info">
Depending on your location, your billing address might need to be in the same country
associated with the payment method for your organization.
</Alert>
<FormList gap={16} class="u-margin-block-start-24">
<InputText
bind:value={$createOrganization.taxId}
id="text"
label="Tax ID"
placeholder="Enter tax ID"
optionalText="(optional)" />
<InputSelect
bind:value={$createOrganization.billingAddress.country}
{options}
label="Country or region"
placeholder="Select country or region"
id="country"
required />
<InputText
bind:value={$createOrganization.billingAddress.streetAddress}
id="address"
label="Street address"
placeholder="Enter street address"
required />
<InputText
bind:value={$createOrganization.billingAddress.addressLine2}
id="address2"
label="Address line 2"
placeholder="Unit number, floor, etc." />
<InputText
bind:value={$createOrganization.billingAddress.city}
id="city"
label="City or suburb"
placeholder="Enter your city"
required />
<FormItem isMultiple>
<InputText
isMultiple
fullWidth
bind:value={$createOrganization.billingAddress.state}
id="state"
label="State"
placeholder="Enter your state"
required />
<InputText
isMultiple
fullWidth
bind:value={$createOrganization.billingAddress.postalCode}
id="zip"
label="Postal code"
placeholder="Enter postal code"
required />
</FormItem>
<p class="text u-margin-block-start-24"><b>Billing address</b></p>
<FormList class="u-margin-block-start-8">
<div class:boxes-wrapper={addressList?.total}>
{#if addressList?.total}
{#each addressList.billingAddresses as address}
<div class="box">
<InputRadio
id={`address-${address.$id}`}
value={address.$id}
name="address"
bind:group={$createOrganization.billingAddressId}>
<div
class="u-line-height-1-5 u-flex u-flex-vertical u-gap-2"
style="padding-inline:0.25rem">
<p class="text">{address.streetAddress}</p>
{#if address?.addressLine2}
<p class="text">{address.addressLine2}</p>
{/if}
<p class="text">{address.city}</p>
<p class="text">{address.state}</p>
<p class="text">{address.postalCode}</p>
<p class="text">{address.country}</p>
</div>
</InputRadio>
</div>
{/each}
{/if}
<div class="box">
{#if addressList?.total}
<InputRadio
id="address"
value={null}
name="address"
bind:group={$createOrganization.billingAddressId}>
<span style="padding-inline:0.25rem">Add new billing address</span>
</InputRadio>
{/if}
{#if $createOrganization.billingAddressId === null}
<FormList gap={16} class="u-margin-block-start-24">
<InputText
bind:value={$createOrganization.taxId}
id="text"
label="Tax ID"
placeholder="Enter tax ID"
optionalText="(optional)" />
<InputSelect
bind:value={$createOrganization.billingAddress.country}
{options}
label="Country or region"
placeholder="Select country or region"
id="country"
required />
<InputText
bind:value={$createOrganization.billingAddress.streetAddress}
id="address"
label="Street address"
placeholder="Enter street address"
required />
<InputText
bind:value={$createOrganization.billingAddress.addressLine2}
id="address2"
label="Address line 2"
placeholder="Unit number, floor, etc." />
<InputText
bind:value={$createOrganization.billingAddress.city}
id="city"
label="City or suburb"
placeholder="Enter your city"
required />
<FormItem isMultiple>
<InputText
isMultiple
fullWidth
bind:value={$createOrganization.billingAddress.state}
id="state"
label="State"
placeholder="Enter your state"
required />
<InputText
isMultiple
fullWidth
bind:value={$createOrganization.billingAddress.postalCode}
id="zip"
label="Postal code"
placeholder="Enter postal code"
required />
</FormItem>
</FormList>
{/if}
</div>
</div>
</FormList>
</WizardStep>
@@ -11,7 +11,8 @@ export const createOrganization = writable<{
name: string;
billingPlan: Tier;
paymentMethodId: string;
billingAddress: Address;
billingAddressId: string;
billingAddress?: Address;
collaborators?: string[];
billingBudget?: number;
taxId?: string;
@@ -21,6 +22,7 @@ export const createOrganization = writable<{
billingPlan: 'tier-1',
paymentMethodId: null,
collaborators: [],
billingAddressId: null,
billingAddress: {
$id: null,
streetAddress: null,
@@ -1,12 +1,11 @@
<script lang="ts">
import { FormItem, FormList, InputSelect, InputText } from '$lib/elements/forms';
import { FormItem, FormList, InputRadio, InputSelect, InputText } from '$lib/elements/forms';
import { WizardStep } from '$lib/layout';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { Alert } from '$lib/components';
import { changeOrganizationTier, currentBillingAddress } from './store';
import { changeOrganizationTier } from './store';
import { organization } from '$lib/stores/organization';
import { deepClone } from '$lib/helpers/object';
import type { AddressesList } from '$lib/sdk/billing';
let options = [
{
@@ -14,8 +13,13 @@
label: 'United States'
}
];
let addressList: AddressesList;
onMount(async () => {
addressList = await sdk.forConsole.billing.listAddresses();
$changeOrganizationTier.billingAddressId = $organization.billingAddressId
? $organization.billingAddressId
: addressList.billingAddresses?.[0]?.$id ?? null;
const countryList = await sdk.forProject.locale.listCountries();
options = countryList.countries.map((country) => {
return {
@@ -23,13 +27,6 @@
label: country.name
};
});
if ($organization.billingAddressId) {
$currentBillingAddress = await sdk.forConsole.billing.getAddress(
$organization.billingAddressId
);
$changeOrganizationTier.billingAddress = deepClone($currentBillingAddress);
}
});
</script>
@@ -37,58 +34,97 @@
<svelte:fragment slot="title">Billing address</svelte:fragment>
<svelte:fragment slot="subtitle">Add a billing address for your organization.</svelte:fragment>
<Alert type="info">
Depending on your location, your billing address might need to be in the same country
associated with the payment method for your organization.
</Alert>
<FormList gap={16} class="u-margin-block-start-24">
<InputText
bind:value={$changeOrganizationTier.taxId}
id="text"
label="Tax ID"
placeholder="Enter tax ID"
optionalText="(optional)" />
<InputSelect
bind:value={$changeOrganizationTier.billingAddress.country}
{options}
label="Country or region"
placeholder="Select country or region"
id="country"
required />
<InputText
bind:value={$changeOrganizationTier.billingAddress.streetAddress}
id="address"
label="Street address"
placeholder="Enter street address"
required />
<InputText
bind:value={$changeOrganizationTier.billingAddress.addressLine2}
id="address2"
label="Address line 2"
placeholder="Unit number, floor, etc." />
<InputText
bind:value={$changeOrganizationTier.billingAddress.city}
id="city"
label="City or suburb"
placeholder="Enter your city"
required />
<FormItem isMultiple>
<InputText
isMultiple
fullWidth
bind:value={$changeOrganizationTier.billingAddress.state}
id="state"
label="State"
placeholder="Enter your state"
required />
<InputText
isMultiple
fullWidth
bind:value={$changeOrganizationTier.billingAddress.postalCode}
id="zip"
label="Postal code"
placeholder="Enter postal code"
required />
</FormItem>
<p class="text u-margin-block-start-24"><b>Billing address</b></p>
<FormList class="u-margin-block-start-8">
<div class:boxes-wrapper={addressList?.total}>
{#if addressList?.total}
{#each addressList.billingAddresses as address}
<div class="box">
<InputRadio
id={`address-${address.$id}`}
value={address.$id}
name="address"
bind:group={$changeOrganizationTier.billingAddressId}>
<div
class="u-line-height-1-5 u-flex u-flex-vertical u-gap-2"
style="padding-inline:0.25rem">
<p class="text">{address.streetAddress}</p>
{#if address?.addressLine2}
<p class="text">{address.addressLine2}</p>
{/if}
<p class="text">{address.city}</p>
<p class="text">{address.state}</p>
<p class="text">{address.postalCode}</p>
<p class="text">{address.country}</p>
</div>
</InputRadio>
</div>
{/each}
{/if}
<div class="box">
{#if addressList?.total}
<InputRadio
id="address"
value={null}
name="address"
bind:group={$changeOrganizationTier.billingAddressId}>
<span style="padding-inline:0.25rem">Add new billing address</span>
</InputRadio>
{/if}
{#if $changeOrganizationTier.billingAddressId === null}
<FormList gap={16} class="u-margin-block-start-24">
<InputText
bind:value={$changeOrganizationTier.taxId}
id="text"
label="Tax ID"
placeholder="Enter tax ID"
optionalText="(optional)" />
<InputSelect
bind:value={$changeOrganizationTier.billingAddress.country}
{options}
label="Country or region"
placeholder="Select country or region"
id="country"
required />
<InputText
bind:value={$changeOrganizationTier.billingAddress.streetAddress}
id="address"
label="Street address"
placeholder="Enter street address"
required />
<InputText
bind:value={$changeOrganizationTier.billingAddress.addressLine2}
id="address2"
label="Address line 2"
placeholder="Unit number, floor, etc." />
<InputText
bind:value={$changeOrganizationTier.billingAddress.city}
id="city"
label="City or suburb"
placeholder="Enter your city"
required />
<FormItem isMultiple>
<InputText
isMultiple
fullWidth
bind:value={$changeOrganizationTier.billingAddress.state}
id="state"
label="State"
placeholder="Enter your state"
required />
<InputText
isMultiple
fullWidth
bind:value={$changeOrganizationTier.billingAddress.postalCode}
id="zip"
label="Postal code"
placeholder="Enter postal code"
required />
</FormItem>
</FormList>
{/if}
</div>
</div>
</FormList>
</WizardStep>
@@ -41,14 +41,15 @@
(plan) => plan.$id === $changeOrganizationTier.billingPlan
);
const totBandwidth = usage?.bandwidth?.length > 0 ? usage.bandwidth[0].value : 0;
const totUsers = usage?.users?.length > 0 ? usage.users[0].value : 0;
$changeOrganizationTier.limitOverflow = {
bandwidth:
usage.bandwidth[0] > plan.bandwidth ? usage.bandwidth[0] - plan.bandwidth : 0,
bandwidth: totBandwidth > plan.bandwidth ? totBandwidth - plan.bandwidth : 0,
storage:
usage.storage[0] > sizeToBytes(plan.storage, 'GB')
? usage.storage[0] - plan.storage
: 0,
users: usage.users[0] > plan.users ? usage.users[0] - plan.users : 0,
users: totUsers > plan.users ? totUsers - plan.users : 0,
executions:
usage.executions[0] > plan.executions ? usage.executions[0] - plan.executions : 0,
members: members.total > plan.members ? members.total - (plan.members || Infinity) : 0
@@ -4,6 +4,7 @@
import { Button, FormList, InputText, InputTextarea } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import { WizardStep } from '$lib/layout';
import type { Coupon } from '$lib/sdk/billing';
import { plansInfo } from '$lib/stores/billing';
import { organization } from '$lib/stores/organization';
import { sdk } from '$lib/stores/sdk';
@@ -21,6 +22,12 @@
let coupon: string = null;
let comment: string = null;
let couponData: Partial<Coupon> = {
code: null,
status: null,
credits: null
};
let showCoupon: boolean = false;
async function fetchCard() {
try {
const card = await sdk.forConsole.billing.getPaymentMethod(
@@ -32,15 +39,30 @@
}
}
async function applyCredit() {
async function addCoupon() {
try {
await sdk.forConsole.billing.getCoupon(coupon);
const response = await sdk.forConsole.billing.getCoupon(coupon);
coupon = null;
couponData = response;
showCoupon = true;
$changeOrganizationTier.couponCode = response.$id;
} catch (error) {
console.log(error);
couponData.code = coupon;
couponData.status = 'error';
showCoupon = true;
}
}
function removeCoupon() {
couponData = {
code: null,
status: null,
credits: null
};
showCoupon = false;
}
$: downgradeToStarter = $changeOrganizationTier.billingPlan === 'tier-0';
$: if (!$isUpgrade) {
$changeOrganizationFinalAction = 'Confirm plan change';
@@ -55,7 +77,6 @@
there is a specific reason you chose to change your plan at this time, please let us
know.
</svelte:fragment>
<!-- TODO: submit feedback -->
<FormList>
<InputTextarea
id="comment"
@@ -102,14 +123,40 @@
{/if}
<Box class="u-margin-block-start-32 u-flex u-flex-vertical u-gap-16" radius="small">
<FormList>
<FormList gap={8}>
<InputText
placeholder="Coupon code"
id="code"
label="Add credits"
disabled={showCoupon && couponData?.status !== 'error'}
bind:value={coupon}>
<Button secondary disabled={!coupon} on:click={applyCredit}>Apply</Button>
<Button
secondary
disabled={(showCoupon && couponData?.status !== 'error') || !coupon}
on:click={addCoupon}>
Apply
</Button>
</InputText>
{#if showCoupon}
{#if couponData?.status === 'error'}
<div>
<span class="icon-exclamation-circle u-color-text-danger" />
<span>
{couponData.code} is not a valid promo code
</span>
</div>
{:else}
<div class="u-flex u-main-space-between u-cross-center">
<div>
<span class="icon-tag u-color-text-success" />
<span>
{couponData.code} applied (-${couponData.credits})
</span>
</div>
<Button text on:click={removeCoupon}>Remove</Button>
</div>
{/if}
{/if}
</FormList>
{#if $changeOrganizationTier.billingPlan !== 'tier-0'}
<span class="u-flex u-main-space-between">
@@ -11,7 +11,8 @@ export const changeOrganizationTier = writable<{
id?: string;
billingPlan: Tier;
paymentMethodId: string;
billingAddress: Address;
billingAddressId: string;
billingAddress?: Address;
billingBudget?: number;
collaborators?: string[];
isOverLimit?: boolean;
@@ -23,12 +24,15 @@ export const changeOrganizationTier = writable<{
members?: number;
};
taxId?: string;
feedbackMessage?: string;
couponCode?: string;
}>({
id: null,
billingPlan: 'tier-1',
paymentMethodId: null,
collaborators: [],
isOverLimit: false,
billingAddressId: null,
billingAddress: {
$id: null,
streetAddress: null,
@@ -40,13 +44,3 @@ export const changeOrganizationTier = writable<{
},
taxId: null
});
export const currentBillingAddress = writable<Address>({
$id: null,
streetAddress: null,
addressLine2: null,
city: null,
state: null,
postalCode: null,
country: null
});