Merge commit
This commit is contained in:
parent
6ed4fede00
commit
98333b8791
|
@ -4,22 +4,30 @@
|
||||||
import { banner } from "../Stores/banner"
|
import { banner } from "../Stores/banner"
|
||||||
import Banner from "./Banner.svelte"
|
import Banner from "./Banner.svelte"
|
||||||
import { fly } from "svelte/transition"
|
import { fly } from "svelte/transition"
|
||||||
|
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Portal target=".banner-container">
|
<Portal target=".banner-container">
|
||||||
<div class="banner">
|
<div class="banner">
|
||||||
{#if $banner.message}
|
{#each $banner.messages as message}
|
||||||
<div transition:fly={{ y: -30 }}>
|
<div transition:fly={{ y: -30 }}>
|
||||||
<Banner
|
<Banner
|
||||||
type={$banner.type}
|
type={message.type}
|
||||||
extraButtonText={$banner.extraButtonText}
|
extraButtonText={message.extraButtonText}
|
||||||
extraButtonAction={$banner.extraButtonAction}
|
extraButtonAction={message.extraButtonAction}
|
||||||
on:change={$banner.onChange}
|
on:change={() => {
|
||||||
|
message.onChange()
|
||||||
|
}}
|
||||||
|
showCloseButton={typeof message.showCloseButton === "boolean"
|
||||||
|
? message.showCloseButton
|
||||||
|
: true}
|
||||||
>
|
>
|
||||||
{$banner.message}
|
<TooltipWrapper tooltip={"test"}>
|
||||||
|
{message.message}
|
||||||
|
</TooltipWrapper>
|
||||||
</Banner>
|
</Banner>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
|
|
||||||
export function createBannerStore() {
|
export function createBannerStore() {
|
||||||
const DEFAULT_CONFIG = {}
|
const DEFAULT_CONFIG = {
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
|
||||||
const banner = writable(DEFAULT_CONFIG)
|
const banner = writable(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
@ -28,9 +30,23 @@ export function createBannerStore() {
|
||||||
await show(config)
|
await show(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queue = async entries => {
|
||||||
|
banner.update(store => {
|
||||||
|
const sorted = [...store.messages, ...entries].sort(
|
||||||
|
(a, b) => a.priority > b.priority
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...store,
|
||||||
|
messages: sorted,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: banner.subscribe,
|
subscribe: banner.subscribe,
|
||||||
showStatus,
|
showStatus,
|
||||||
|
show,
|
||||||
|
queue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,6 @@
|
||||||
transform: scale(0.75);
|
transform: scale(0.75);
|
||||||
}
|
}
|
||||||
.icon-small {
|
.icon-small {
|
||||||
margin-top: -2px;
|
margin-bottom: -2px;
|
||||||
margin-bottom: -5px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -34,6 +34,7 @@ export { default as Layout } from "./Layout/Layout.svelte"
|
||||||
export { default as Page } from "./Layout/Page.svelte"
|
export { default as Page } from "./Layout/Page.svelte"
|
||||||
export { default as Link } from "./Link/Link.svelte"
|
export { default as Link } from "./Link/Link.svelte"
|
||||||
export { default as Tooltip } from "./Tooltip/Tooltip.svelte"
|
export { default as Tooltip } from "./Tooltip/Tooltip.svelte"
|
||||||
|
export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte"
|
||||||
export { default as Menu } from "./Menu/Menu.svelte"
|
export { default as Menu } from "./Menu/Menu.svelte"
|
||||||
export { default as MenuSection } from "./Menu/Section.svelte"
|
export { default as MenuSection } from "./Menu/Section.svelte"
|
||||||
export { default as MenuSeparator } from "./Menu/Separator.svelte"
|
export { default as MenuSeparator } from "./Menu/Separator.svelte"
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import { NotificationDisplay, BannerDisplay } from "@budibase/bbui"
|
import { NotificationDisplay, BannerDisplay } from "@budibase/bbui"
|
||||||
import { parse, stringify } from "qs"
|
import { parse, stringify } from "qs"
|
||||||
import HelpIcon from "components/common/HelpIcon.svelte"
|
import HelpIcon from "components/common/HelpIcon.svelte"
|
||||||
|
import LicensingOverlays from "components/portal/licensing/LicensingOverlays.svelte"
|
||||||
|
|
||||||
const queryHandler = { parse, stringify }
|
const queryHandler = { parse, stringify }
|
||||||
</script>
|
</script>
|
||||||
|
@ -12,6 +13,9 @@
|
||||||
<BannerDisplay />
|
<BannerDisplay />
|
||||||
|
|
||||||
<NotificationDisplay />
|
<NotificationDisplay />
|
||||||
|
|
||||||
|
<LicensingOverlays />
|
||||||
|
|
||||||
<Router {routes} config={{ queryHandler }} />
|
<Router {routes} config={{ queryHandler }} />
|
||||||
<div class="modal-container" />
|
<div class="modal-container" />
|
||||||
<HelpIcon />
|
<HelpIcon />
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { getFrontendStore } from "./store/frontend"
|
import { getFrontendStore } from "./store/frontend"
|
||||||
import { getAutomationStore } from "./store/automation"
|
import { getAutomationStore } from "./store/automation"
|
||||||
|
import { getTemporalStore } from "./store/temporal"
|
||||||
import { getThemeStore } from "./store/theme"
|
import { getThemeStore } from "./store/theme"
|
||||||
import { derived } from "svelte/store"
|
import { derived } from "svelte/store"
|
||||||
import { findComponent, findComponentPath } from "./componentUtils"
|
import { findComponent, findComponentPath } from "./componentUtils"
|
||||||
|
@ -8,6 +9,7 @@ import { RoleUtils } from "@budibase/frontend-core"
|
||||||
export const store = getFrontendStore()
|
export const store = getFrontendStore()
|
||||||
export const automationStore = getAutomationStore()
|
export const automationStore = getAutomationStore()
|
||||||
export const themeStore = getThemeStore()
|
export const themeStore = getThemeStore()
|
||||||
|
export const temporalStore = getTemporalStore()
|
||||||
|
|
||||||
export const selectedScreen = derived(store, $store => {
|
export const selectedScreen = derived(store, $store => {
|
||||||
return $store.screens.find(screen => screen._id === $store.selectedScreenId)
|
return $store.screens.find(screen => screen._id === $store.selectedScreenId)
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { createLocalStorageStore } from "@budibase/frontend-core"
|
||||||
|
import { get } from "svelte/store"
|
||||||
|
|
||||||
|
export const getTemporalStore = () => {
|
||||||
|
const initialValue = {}
|
||||||
|
|
||||||
|
//const appId = window["##BUDIBASE_APP_ID##"] || "app"
|
||||||
|
const localStorageKey = `${123}.bb-temporal`
|
||||||
|
const store = createLocalStorageStore(localStorageKey, initialValue)
|
||||||
|
|
||||||
|
const setExpiring = (key, data, duration) => {
|
||||||
|
const updated = {
|
||||||
|
...data,
|
||||||
|
expiry: Date.now() + duration * 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
store.update(state => ({
|
||||||
|
...state,
|
||||||
|
[key]: updated,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpiring = key => {
|
||||||
|
const entry = get(store)[key]
|
||||||
|
if (!entry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentExpiry = entry.expiry
|
||||||
|
if (currentExpiry < Date.now()) {
|
||||||
|
store.update(state => {
|
||||||
|
delete state[key]
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: store.subscribe,
|
||||||
|
actions: { setExpiring, getExpiring },
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import TemplateCard from "components/common/TemplateCard.svelte"
|
import TemplateCard from "components/common/TemplateCard.svelte"
|
||||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||||
|
import { licensing } from "stores/portal"
|
||||||
|
|
||||||
export let templates
|
export let templates
|
||||||
|
|
||||||
|
@ -96,6 +97,7 @@
|
||||||
backgroundColour={templateEntry.background}
|
backgroundColour={templateEntry.background}
|
||||||
icon={templateEntry.icon}
|
icon={templateEntry.icon}
|
||||||
>
|
>
|
||||||
|
{#if $licensing?.usageMetrics?.apps < 100}
|
||||||
<Button
|
<Button
|
||||||
cta
|
cta
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
@ -105,6 +107,7 @@
|
||||||
>
|
>
|
||||||
Use template
|
Use template
|
||||||
</Button>
|
</Button>
|
||||||
|
{/if}
|
||||||
<a
|
<a
|
||||||
href={templateEntry.url}
|
href={templateEntry.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
<script>
|
||||||
|
import { Modal, ModalContent, Body } from "@budibase/bbui"
|
||||||
|
import { auth, admin } from "stores/portal"
|
||||||
|
|
||||||
|
export let onDismiss = () => {}
|
||||||
|
export let onShow = () => {}
|
||||||
|
|
||||||
|
let accountDowngradeModal
|
||||||
|
|
||||||
|
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
accountDowngradeModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hide() {
|
||||||
|
accountDowngradeModal.hide()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={accountDowngradeModal} on:show={onShow} on:hide={onDismiss}>
|
||||||
|
<ModalContent
|
||||||
|
title="Your account is now on the Free plan"
|
||||||
|
size="M"
|
||||||
|
showCancelButton={$auth.user.accountPortalAccess}
|
||||||
|
confirmText={$auth.user.accountPortalAccess ? "Upgrade" : "Confirm"}
|
||||||
|
onConfirm={$auth.user.accountPortalAccess
|
||||||
|
? () => {
|
||||||
|
window.location.href = upgradeUrl
|
||||||
|
}
|
||||||
|
: null}
|
||||||
|
>
|
||||||
|
<Body>
|
||||||
|
The payment for your Business Subscription failed and we have downgraded
|
||||||
|
your account to the <span class="free-plan">Free plan</span>.
|
||||||
|
</Body>
|
||||||
|
<Body>
|
||||||
|
Update to Business to get all your apps and user sessions back up and
|
||||||
|
running.
|
||||||
|
</Body>
|
||||||
|
{#if !$auth.user.accountPortalAccess}
|
||||||
|
<Body>Please contact the account holder.</Body>
|
||||||
|
{/if}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.free-plan {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script>
|
||||||
|
import { Modal, ModalContent, Body } from "@budibase/bbui"
|
||||||
|
import { auth, admin } from "stores/portal"
|
||||||
|
|
||||||
|
export let onDismiss = () => {}
|
||||||
|
|
||||||
|
let appLimitModal
|
||||||
|
|
||||||
|
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
appLimitModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hide() {
|
||||||
|
appLimitModal.hide()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={appLimitModal} on:hide={onDismiss}>
|
||||||
|
<ModalContent
|
||||||
|
title="Upgrade to get more apps"
|
||||||
|
size="M"
|
||||||
|
showCancelButton={false}
|
||||||
|
confirmText={$auth.user.accountPortalAccess ? "Upgrade" : "Confirm"}
|
||||||
|
onConfirm={$auth.user.accountPortalAccess
|
||||||
|
? () => {
|
||||||
|
window.location.href = upgradeUrl
|
||||||
|
}
|
||||||
|
: null}
|
||||||
|
>
|
||||||
|
<Body>
|
||||||
|
You are currently on our <span class="free-plan">Free plan</span>. Upgrade
|
||||||
|
to our Pro plan to get unlimited apps.
|
||||||
|
</Body>
|
||||||
|
{#if !$auth.user.accountPortalAccess}
|
||||||
|
<Body>Please contact the account holder.</Body>
|
||||||
|
{/if}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.free-plan {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,54 @@
|
||||||
|
<script>
|
||||||
|
import { Modal, ModalContent, Body } from "@budibase/bbui"
|
||||||
|
import { licensing, auth, admin } from "stores/portal"
|
||||||
|
|
||||||
|
export let onDismiss = () => {}
|
||||||
|
export let onShow = () => {}
|
||||||
|
|
||||||
|
let sessionsModal
|
||||||
|
|
||||||
|
const outOfSessionsTitle = "You are almost out of sessions"
|
||||||
|
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
|
||||||
|
|
||||||
|
$: daysRemaining = $licensing.quotaResetDaysRemaining
|
||||||
|
$: sessionsUsed = $licensing.usageMetrics?.dayPasses
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
sessionsModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hide() {
|
||||||
|
sessionsModal.hide()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={sessionsModal} on:show={onShow} on:hide={onDismiss}>
|
||||||
|
{#if $auth.user.accountPortalAccess}
|
||||||
|
<ModalContent
|
||||||
|
title={outOfSessionsTitle}
|
||||||
|
size="M"
|
||||||
|
confirmText="Upgrade"
|
||||||
|
onConfirm={() => {
|
||||||
|
window.location.href = upgradeUrl
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body>
|
||||||
|
You have used <span class="session_percent">{sessionsUsed}%</span> of
|
||||||
|
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
|
||||||
|
? ""
|
||||||
|
: "s"} remaining.
|
||||||
|
</Body>
|
||||||
|
<Body>Upgrade your account to prevent your apps from going offline.</Body>
|
||||||
|
</ModalContent>
|
||||||
|
{:else}
|
||||||
|
<ModalContent title={outOfSessionsTitle} size="M" showCancelButton={false}>
|
||||||
|
<Body>
|
||||||
|
You have used <span class="session_percent">{sessionsUsed}%</span> of
|
||||||
|
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
|
||||||
|
? ""
|
||||||
|
: "s"} remaining.
|
||||||
|
</Body>
|
||||||
|
<Body>Please contact your account holder.</Body>
|
||||||
|
</ModalContent>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
|
@ -0,0 +1,117 @@
|
||||||
|
<script>
|
||||||
|
import { licensing, auth } from "stores/portal"
|
||||||
|
import { temporalStore } from "builderStore"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
import DayPassWarningModal from "./DayPassWarningModal.svelte"
|
||||||
|
import PaymentFailedModal from "./PaymentFailedModal.svelte"
|
||||||
|
import AccountDowngradedModal from "./AccountDowngradedModal.svelte"
|
||||||
|
import { ExpiringKeys } from "./constants"
|
||||||
|
import { getBanners } from "./banners"
|
||||||
|
import { banner } from "@budibase/bbui"
|
||||||
|
|
||||||
|
const oneDayInSeconds = 86400
|
||||||
|
|
||||||
|
let queuedBanners = []
|
||||||
|
let queuedModals = []
|
||||||
|
let dayPassModal
|
||||||
|
let paymentFailedModal
|
||||||
|
let accountDowngradeModal
|
||||||
|
let userLoaded = false
|
||||||
|
let loaded = false
|
||||||
|
let licensingLoaded = false
|
||||||
|
let currentModalCfg = null
|
||||||
|
|
||||||
|
const processModals = () => {
|
||||||
|
const defaultCacheFn = key => {
|
||||||
|
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissableModals = [
|
||||||
|
{
|
||||||
|
key: ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL,
|
||||||
|
criteria: () => {
|
||||||
|
return $licensing?.usageMetrics?.dayPasses >= 90
|
||||||
|
},
|
||||||
|
action: () => {
|
||||||
|
dayPassModal.show()
|
||||||
|
},
|
||||||
|
cache: () => {
|
||||||
|
defaultCacheFn(ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ExpiringKeys.LICENSING_PAYMENT_FAILED,
|
||||||
|
criteria: () => {
|
||||||
|
return $licensing.accountPastDue
|
||||||
|
},
|
||||||
|
action: () => {
|
||||||
|
paymentFailedModal.show()
|
||||||
|
},
|
||||||
|
cache: () => {
|
||||||
|
defaultCacheFn(ExpiringKeys.LICENSING_PAYMENT_FAILED)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ExpiringKeys.LICENSING_ACCOUNT_DOWNGRADED_MODAL,
|
||||||
|
criteria: () => {
|
||||||
|
return $licensing?.accountDowngraded
|
||||||
|
},
|
||||||
|
action: () => {
|
||||||
|
accountDowngradeModal.show()
|
||||||
|
},
|
||||||
|
cache: () => {
|
||||||
|
defaultCacheFn(ExpiringKeys.LICENSING_ACCOUNT_DOWNGRADED_MODAL)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return dismissableModals.filter(modal => {
|
||||||
|
return !temporalStore.actions.getExpiring(modal.key) && modal.criteria()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (userLoaded && licensingLoaded && loaded) {
|
||||||
|
queuedModals = processModals()
|
||||||
|
queuedBanners = getBanners()
|
||||||
|
showNext()
|
||||||
|
banner.queue(queuedBanners)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNext = () => {
|
||||||
|
if (currentModalCfg) {
|
||||||
|
currentModalCfg.cache()
|
||||||
|
}
|
||||||
|
if (queuedModals.length) {
|
||||||
|
currentModalCfg = queuedModals.shift()
|
||||||
|
currentModalCfg.action()
|
||||||
|
} else {
|
||||||
|
currentModalCfg = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
auth.subscribe(state => {
|
||||||
|
if (state.user && !userLoaded) {
|
||||||
|
userLoaded = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
licensing.subscribe(state => {
|
||||||
|
if (state.usageMetrics && !licensingLoaded) {
|
||||||
|
licensingLoaded = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
temporalStore.subscribe(state => {
|
||||||
|
console.log("Stored temporal ", state)
|
||||||
|
})
|
||||||
|
|
||||||
|
loaded = true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DayPassWarningModal bind:this={dayPassModal} onDismiss={showNext} />
|
||||||
|
<PaymentFailedModal bind:this={paymentFailedModal} onDismiss={showNext} />
|
||||||
|
<AccountDowngradedModal
|
||||||
|
bind:this={accountDowngradeModal}
|
||||||
|
onDismiss={showNext}
|
||||||
|
/>
|
|
@ -0,0 +1,87 @@
|
||||||
|
<script>
|
||||||
|
import { Modal, ModalContent, Body, TooltipWrapper } from "@budibase/bbui"
|
||||||
|
import { auth, admin, licensing } from "stores/portal"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
|
||||||
|
export let onDismiss = () => {}
|
||||||
|
export let onShow = () => {}
|
||||||
|
|
||||||
|
let paymentFailedModal
|
||||||
|
let pastDueAt
|
||||||
|
|
||||||
|
const paymentFailedTitle = "Payment failed"
|
||||||
|
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
paymentFailedModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hide() {
|
||||||
|
paymentFailedModal.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
auth.subscribe(state => {
|
||||||
|
if (state.user && state.user.license?.billing?.subscription) {
|
||||||
|
pastDueAt = new Date(
|
||||||
|
state.user.license?.billing?.subscription.pastDueAt * 1000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={paymentFailedModal} on:show={onShow} on:hide={onDismiss}>
|
||||||
|
{#if $auth.user.accountPortalAccess}
|
||||||
|
<ModalContent
|
||||||
|
title={paymentFailedTitle}
|
||||||
|
size="M"
|
||||||
|
confirmText="Upgrade"
|
||||||
|
onConfirm={() => {
|
||||||
|
window.location.href = upgradeUrl
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body>The payment for your business plan subscription has failed</Body>
|
||||||
|
<Body>
|
||||||
|
Please upgrade your billing details before your account gets downgraded
|
||||||
|
to the free plan
|
||||||
|
</Body>
|
||||||
|
<Body weight={800}>
|
||||||
|
<div class="tooltip-root">
|
||||||
|
{`${$licensing.paymentDueDaysRemaining} day${
|
||||||
|
$licensing.paymentDueDaysRemaining == 1 ? "" : "s"
|
||||||
|
} remaining`}
|
||||||
|
<span class="tooltip">
|
||||||
|
<TooltipWrapper tooltip={pastDueAt.toString()} size="S" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Body>
|
||||||
|
</ModalContent>
|
||||||
|
{:else}
|
||||||
|
<ModalContent title={paymentFailedTitle} size="M" showCancelButton={false}>
|
||||||
|
<Body>The payment for your business plan subscription has failed</Body>
|
||||||
|
<Body>
|
||||||
|
Please upgrade your billing details before your account gets downgraded
|
||||||
|
to the free plan
|
||||||
|
</Body>
|
||||||
|
<Body>Please contact your account holder.</Body>
|
||||||
|
<Body weight={800}>
|
||||||
|
<div class="tooltip-root">
|
||||||
|
{`${$licensing.paymentDueDaysRemaining} day${
|
||||||
|
$licensing.paymentDueDaysRemaining == 1 ? "" : "s"
|
||||||
|
} remaining`}
|
||||||
|
<span class="tooltip">
|
||||||
|
<TooltipWrapper tooltip={pastDueAt.toString()} size="S" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Body>
|
||||||
|
</ModalContent>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tooltip-root {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { ExpiringKeys } from "./constants"
|
||||||
|
import { temporalStore } from "builderStore"
|
||||||
|
import { admin, auth, licensing } from "stores/portal"
|
||||||
|
import { get } from "svelte/store"
|
||||||
|
|
||||||
|
const oneDayInSeconds = 86400
|
||||||
|
const upgradeUrl = `${get(admin).accountPortalUrl}/portal/upgrade`
|
||||||
|
|
||||||
|
const defaultCacheFn = key => {
|
||||||
|
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAction = key => {
|
||||||
|
if (!get(auth).user.accountPortalAccess) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
extraButtonText: "Upgrade Plan",
|
||||||
|
extraButtonAction: () => {
|
||||||
|
defaultCacheFn(key)
|
||||||
|
window.location.href = upgradeUrl
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUsageInfoBanner = (metricKey, metricLabel, cacheKey, percentage) => {
|
||||||
|
const appAuth = get(auth)
|
||||||
|
const appLicensing = get(licensing)
|
||||||
|
|
||||||
|
let bannerConfig = {
|
||||||
|
key: cacheKey,
|
||||||
|
type: "info",
|
||||||
|
onChange: () => {
|
||||||
|
defaultCacheFn(cacheKey)
|
||||||
|
},
|
||||||
|
message: `You have used ${
|
||||||
|
appLicensing?.usageMetrics[metricKey]
|
||||||
|
}% of your monthly usage of ${metricLabel} with ${
|
||||||
|
appLicensing.quotaResetDaysRemaining
|
||||||
|
} day${
|
||||||
|
appLicensing.quotaResetDaysRemaining == 1 ? "" : "s"
|
||||||
|
} remaining. All apps will be taken offline if this limit is reached. ${
|
||||||
|
appAuth.user.accountPortalAccess
|
||||||
|
? ""
|
||||||
|
: "Please contact your account holder."
|
||||||
|
}`,
|
||||||
|
criteria: () => {
|
||||||
|
return appLicensing?.usageMetrics[metricKey] >= percentage
|
||||||
|
},
|
||||||
|
priority: 0, //Banners.Priority 0, 1, 2 ??
|
||||||
|
}
|
||||||
|
|
||||||
|
return !get(auth).user.accountPortalAccess
|
||||||
|
? bannerConfig
|
||||||
|
: {
|
||||||
|
...bannerConfig,
|
||||||
|
...defaultAction(cacheKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDayPassBanner = () => {
|
||||||
|
const appAuth = get(auth)
|
||||||
|
if (get(licensing)?.usageMetrics["dayPasses"] >= 100) {
|
||||||
|
return {
|
||||||
|
key: "max_dayPasses",
|
||||||
|
type: "negative",
|
||||||
|
criteria: () => {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
message: `Your apps are currently offline. You have exceeded your plans limit for Day Passes. ${
|
||||||
|
appAuth.user.accountPortalAccess
|
||||||
|
? ""
|
||||||
|
: "Please contact your account holder."
|
||||||
|
}`,
|
||||||
|
...defaultAction(),
|
||||||
|
showCloseButton: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildUsageInfoBanner(
|
||||||
|
"dayPasses",
|
||||||
|
"Day Passes",
|
||||||
|
ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER,
|
||||||
|
90
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPaymentFailedBanner = () => {
|
||||||
|
return {
|
||||||
|
key: "payment_Failed",
|
||||||
|
type: "negative",
|
||||||
|
criteria: () => {
|
||||||
|
return get(licensing)?.accountPastDue
|
||||||
|
},
|
||||||
|
message: `Payment Failed - Please update your billing details or your account will be downgrades in
|
||||||
|
${get(licensing)?.paymentDueDaysRemaining} day${
|
||||||
|
get(licensing)?.paymentDueDaysRemaining == 1 ? "" : "s"
|
||||||
|
}`,
|
||||||
|
...defaultAction(),
|
||||||
|
showCloseButton: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBanners = () => {
|
||||||
|
return [
|
||||||
|
buildPaymentFailedBanner(),
|
||||||
|
buildDayPassBanner(ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER),
|
||||||
|
buildUsageInfoBanner(
|
||||||
|
"rows",
|
||||||
|
"Rows",
|
||||||
|
ExpiringKeys.LICENSING_ROWS_WARNING_BANNER,
|
||||||
|
90
|
||||||
|
),
|
||||||
|
buildUsageInfoBanner(
|
||||||
|
"automations",
|
||||||
|
"Automations",
|
||||||
|
ExpiringKeys.LICENSING_AUTOMATIONS_WARNING_BANNER,
|
||||||
|
90
|
||||||
|
),
|
||||||
|
buildUsageInfoBanner(
|
||||||
|
"queries",
|
||||||
|
"Queries",
|
||||||
|
ExpiringKeys.LICENSING_QUERIES_WARNING_BANNER,
|
||||||
|
90 // could be an array [50,75,90]
|
||||||
|
),
|
||||||
|
].filter(licensingBanner => {
|
||||||
|
return (
|
||||||
|
!temporalStore.actions.getExpiring(licensingBanner.key) &&
|
||||||
|
licensingBanner.criteria()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
export const ExpiringKeys = {
|
||||||
|
LICENSING_DAYPASS_WARNING_MODAL: "licensing_daypass_warning_90_modal",
|
||||||
|
LICENSING_DAYPASS_WARNING_BANNER: "licensing_daypass_warning_90_banner",
|
||||||
|
LICENSING_PAYMENT_FAILED: "licensing_payment_failed",
|
||||||
|
LICENSING_ACCOUNT_DOWNGRADED_MODAL: "licensing_account_downgraded_modal",
|
||||||
|
LICENSING_APP_LIMIT_MODAL: "licensing_app_limit_modal",
|
||||||
|
LICENSING_ROWS_WARNING_BANNER: "licensing_rows_warning_banner",
|
||||||
|
LICENSING_AUTOMATIONS_WARNING_BANNER: "licensing_automations_warning_banner",
|
||||||
|
LICENSING_QUERIES_WARNING_BANNER: "licensing_automations_warning_banner",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StripeStatus = {
|
||||||
|
PAST_DUE: "past_due",
|
||||||
|
ACTIVE: "active",
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { isActive, redirect, params } from "@roxi/routify"
|
import { isActive, redirect, params } from "@roxi/routify"
|
||||||
import { admin, auth } from "stores/portal"
|
import { admin, auth, licensing } from "stores/portal"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { CookieUtils, Constants } from "@budibase/frontend-core"
|
import { CookieUtils, Constants } from "@budibase/frontend-core"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
@ -63,6 +63,9 @@
|
||||||
await auth.getSelf()
|
await auth.getSelf()
|
||||||
await admin.init()
|
await admin.init()
|
||||||
|
|
||||||
|
await licensing.getQuotaUsage()
|
||||||
|
await licensing.getUsageMetrics()
|
||||||
|
|
||||||
// Set init info if present
|
// Set init info if present
|
||||||
if ($params["?template"]) {
|
if ($params["?template"]) {
|
||||||
await auth.setInitInfo({ init_template: $params["?template"] })
|
await auth.setInitInfo({ init_template: $params["?template"] })
|
||||||
|
|
|
@ -13,12 +13,14 @@
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||||
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
|
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
|
||||||
|
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { templates } from "stores/portal"
|
import { templates, licensing } from "stores/portal"
|
||||||
|
|
||||||
let loaded = $templates?.length
|
let loaded = $templates?.length
|
||||||
let template
|
let template
|
||||||
let creationModal = false
|
let creationModal = false
|
||||||
|
let appLimitModal
|
||||||
let creatingApp = false
|
let creatingApp = false
|
||||||
|
|
||||||
const welcomeBody =
|
const welcomeBody =
|
||||||
|
@ -29,6 +31,8 @@
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
await templates.load()
|
await templates.load()
|
||||||
|
await licensing.getQuotaUsage()
|
||||||
|
await licensing.getUsageMetrics()
|
||||||
if ($templates?.length === 0) {
|
if ($templates?.length === 0) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
"There was a problem loading quick start templates."
|
"There was a problem loading quick start templates."
|
||||||
|
@ -41,10 +45,14 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const initiateAppCreation = () => {
|
const initiateAppCreation = () => {
|
||||||
|
if ($licensing.usageMetrics.apps >= 100) {
|
||||||
|
appLimitModal.show()
|
||||||
|
} else {
|
||||||
template = null
|
template = null
|
||||||
creationModal.show()
|
creationModal.show()
|
||||||
creatingApp = true
|
creatingApp = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stopAppCreation = () => {
|
const stopAppCreation = () => {
|
||||||
template = null
|
template = null
|
||||||
|
@ -52,10 +60,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const initiateAppImport = () => {
|
const initiateAppImport = () => {
|
||||||
|
if ($licensing.usageMetrics.apps >= 100) {
|
||||||
|
appLimitModal.show()
|
||||||
|
} else {
|
||||||
template = { fromFile: true }
|
template = { fromFile: true }
|
||||||
creationModal.show()
|
creationModal.show()
|
||||||
creatingApp = true
|
creatingApp = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page wide>
|
<Page wide>
|
||||||
|
@ -121,6 +133,7 @@
|
||||||
>
|
>
|
||||||
<CreateAppModal {template} />
|
<CreateAppModal {template} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<AppLimitModal bind:this={appLimitModal} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.title .welcome > .buttons {
|
.title .welcome > .buttons {
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||||
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
|
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
|
||||||
import ExportAppModal from "components/start/ExportAppModal.svelte"
|
import ExportAppModal from "components/start/ExportAppModal.svelte"
|
||||||
|
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
|
||||||
|
|
||||||
import { store, automationStore } from "builderStore"
|
import { store, automationStore } from "builderStore"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
@ -34,6 +35,7 @@
|
||||||
let creationModal
|
let creationModal
|
||||||
let updatingModal
|
let updatingModal
|
||||||
let exportModal
|
let exportModal
|
||||||
|
let appLimitModal
|
||||||
let creatingApp = false
|
let creatingApp = false
|
||||||
let loaded = $apps?.length || $templates?.length
|
let loaded = $apps?.length || $templates?.length
|
||||||
let searchTerm = ""
|
let searchTerm = ""
|
||||||
|
@ -126,8 +128,10 @@
|
||||||
return `${app.name} - Automation error (${errorCount(errors)})`
|
return `${app.name} - Automation error (${errorCount(errors)})`
|
||||||
}
|
}
|
||||||
|
|
||||||
const initiateAppCreation = () => {
|
const initiateAppCreation = async () => {
|
||||||
if ($apps?.length) {
|
if ($licensing.usageMetrics.apps >= 100) {
|
||||||
|
appLimitModal.show()
|
||||||
|
} else if ($apps?.length) {
|
||||||
$goto("/builder/portal/apps/create")
|
$goto("/builder/portal/apps/create")
|
||||||
} else {
|
} else {
|
||||||
template = null
|
template = null
|
||||||
|
@ -227,6 +231,10 @@
|
||||||
try {
|
try {
|
||||||
await apps.load()
|
await apps.load()
|
||||||
await templates.load()
|
await templates.load()
|
||||||
|
|
||||||
|
await licensing.getQuotaUsage()
|
||||||
|
await licensing.getUsageMetrics()
|
||||||
|
|
||||||
if ($templates?.length === 0) {
|
if ($templates?.length === 0) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
"There was a problem loading quick start templates."
|
"There was a problem loading quick start templates."
|
||||||
|
@ -243,10 +251,6 @@
|
||||||
notifications.error("Error loading apps and templates")
|
notifications.error("Error loading apps and templates")
|
||||||
}
|
}
|
||||||
loaded = true
|
loaded = true
|
||||||
|
|
||||||
//Testing
|
|
||||||
await licensing.getQuotaUsage()
|
|
||||||
await licensing.getTestData()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -415,6 +419,8 @@
|
||||||
<ExportAppModal app={selectedApp} />
|
<ExportAppModal app={selectedApp} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<AppLimitModal bind:this={appLimitModal} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.appTable {
|
.appTable {
|
||||||
border-top: var(--border-light);
|
border-top: var(--border-light);
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
import { writable, get } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { auth } from "stores/portal"
|
import { auth } from "stores/portal"
|
||||||
|
import { Constants } from "@budibase/frontend-core"
|
||||||
|
import { StripeStatus } from "components/portal/licensing/constants"
|
||||||
|
|
||||||
export const createLicensingStore = () => {
|
export const createLicensingStore = () => {
|
||||||
const DEFAULT = {
|
const DEFAULT = {
|
||||||
plans: {},
|
plans: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oneDayInMilliseconds = 86400000
|
||||||
|
|
||||||
const store = writable(DEFAULT)
|
const store = writable(DEFAULT)
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
@ -19,65 +23,86 @@ export const createLicensingStore = () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getTestData: async () => {
|
getUsageMetrics: async () => {
|
||||||
const tenantId = get(auth).tenantId
|
|
||||||
console.log("Tenant ", tenantId)
|
|
||||||
|
|
||||||
const license = get(auth).user.license
|
|
||||||
console.log("User LICENSE ", license)
|
|
||||||
|
|
||||||
// Pull out the usage.
|
|
||||||
const quota = get(store).quotaUsage
|
const quota = get(store).quotaUsage
|
||||||
console.log("Quota usage", quota)
|
const license = get(auth).user.license
|
||||||
|
const now = Date.now()
|
||||||
|
const nowSeconds = now / 1000
|
||||||
|
|
||||||
// Would only initialise the usage elements if the account element is present.
|
const getMetrics = (keys, license, quota) => {
|
||||||
console.log("User account ", get(auth).user.account)
|
if (!license || !quota || !keys) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return keys.reduce((acc, key) => {
|
||||||
|
const quotaLimit = license[key].value
|
||||||
|
const quotaUsed = (quota[key] / quotaLimit) * 100
|
||||||
|
|
||||||
//separate into functions that pass in both the usage and quota
|
// Catch for sessions
|
||||||
//easier to test
|
key = key === "sessions" ? "dayPasses" : key
|
||||||
|
|
||||||
const getMonthlyMetrics = (license, quota) => {
|
acc[key] = quotaLimit > -1 ? Math.round(quotaUsed) : -1
|
||||||
return ["sessions", "queries", "automations"].reduce((acc, key) => {
|
|
||||||
const quotaLimit = license.quotas.usage.monthly[key].value
|
|
||||||
acc[key] =
|
|
||||||
quotaLimit > -1
|
|
||||||
? (quota.monthly.current[key] / quotaLimit) * 100
|
|
||||||
: -1
|
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
const monthlyMetrics = getMetrics(
|
||||||
|
["sessions", "queries", "automations"],
|
||||||
|
license.quotas.usage.monthly,
|
||||||
|
quota.monthly.current
|
||||||
|
)
|
||||||
|
const staticMetrics = getMetrics(
|
||||||
|
["apps", "rows"],
|
||||||
|
license.quotas.usage.static,
|
||||||
|
quota.usageQuota
|
||||||
|
)
|
||||||
|
|
||||||
const getStaticMetrics = (license, quota) => {
|
// DEBUG
|
||||||
return ["apps", "rows"].reduce((acc, key) => {
|
console.log("Store licensing val ", {
|
||||||
const quotaLimit = license.quotas.usage.monthly[key].value
|
...monthlyMetrics,
|
||||||
acc[key] =
|
...staticMetrics,
|
||||||
quotaLimit > -1 ? (quota.usageQuota[key] / quotaLimit) * 100 : -1
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const modQuotaStr = JSON.stringify(quota)
|
|
||||||
const cloneQuota = JSON.parse(modQuotaStr)
|
|
||||||
cloneQuota.monthly.current.sessions = 4
|
|
||||||
|
|
||||||
const monthlyMetrics = getMonthlyMetrics(license, cloneQuota)
|
|
||||||
const staticMetrics = getStaticMetrics(license, cloneQuota)
|
|
||||||
|
|
||||||
console.log("Monthly Usage Metrics ", monthlyMetrics)
|
|
||||||
console.log("Static Usage Metrics ", staticMetrics)
|
|
||||||
|
|
||||||
const flagged = Object.keys(monthlyMetrics).filter(key => {
|
|
||||||
return monthlyMetrics[key] >= 100
|
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(flagged)
|
let subscriptionDaysRemaining
|
||||||
|
if (license?.billing?.subscription) {
|
||||||
|
const currentPeriodEnd = license.billing.subscription.currentPeriodEnd
|
||||||
|
const currentPeriodEndMilliseconds = currentPeriodEnd * 1000
|
||||||
|
|
||||||
// store.update(state => {
|
subscriptionDaysRemaining = Math.round(
|
||||||
// return {
|
(currentPeriodEndMilliseconds - now) / oneDayInMilliseconds
|
||||||
// ...state,
|
)
|
||||||
// metrics,
|
}
|
||||||
// }
|
|
||||||
// })
|
const quotaResetDaysRemaining =
|
||||||
|
quota.quotaReset > now
|
||||||
|
? Math.round((quota.quotaReset - now) / oneDayInMilliseconds)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const accountDowngraded =
|
||||||
|
license?.billing?.subscription?.status === StripeStatus.PAST_DUE &&
|
||||||
|
license?.plan === Constants.PlanType.FREE
|
||||||
|
|
||||||
|
const accountPastDue =
|
||||||
|
nowSeconds >= license?.billing?.subscription?.currentPeriodEnd &&
|
||||||
|
nowSeconds <= license?.billing?.subscription?.pastDueAt &&
|
||||||
|
license?.billing?.subscription?.status === StripeStatus.PAST_DUE &&
|
||||||
|
!accountDowngraded
|
||||||
|
|
||||||
|
const pastDueAtSeconds = license?.billing?.subscription?.pastDueAt
|
||||||
|
const pastDueAtMilliseconds = pastDueAtSeconds * 1000
|
||||||
|
const paymentDueDaysRemaining = Math.round(
|
||||||
|
(pastDueAtMilliseconds - now) / oneDayInMilliseconds
|
||||||
|
)
|
||||||
|
|
||||||
|
store.update(state => {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
|
||||||
|
subscriptionDaysRemaining,
|
||||||
|
paymentDueDaysRemaining,
|
||||||
|
quotaResetDaysRemaining,
|
||||||
|
accountDowngraded,
|
||||||
|
accountPastDue,
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue