@antelopejs/dms-saas 0.1.8 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -13
- package/dist/auth/subscription-access-gate.d.ts +1 -1
- package/dist/billing-state/index.d.ts +1 -0
- package/dist/billing-state/index.js +1 -0
- package/dist/billing-state/index.js.map +1 -1
- package/dist/billing-state/past-due-banner.d.ts +4 -0
- package/dist/billing-state/past-due-banner.js +25 -0
- package/dist/billing-state/past-due-banner.js.map +1 -0
- package/dist/config/runtime.d.ts +6 -1
- package/dist/config/runtime.js +50 -1
- package/dist/config/runtime.js.map +1 -1
- package/dist/crons/recompute-billing-state.js +4 -1
- package/dist/crons/recompute-billing-state.js.map +1 -1
- package/dist/data-api/platformOwner/index.d.ts +1 -0
- package/dist/data-api/platformOwner/index.js +1 -0
- package/dist/data-api/platformOwner/index.js.map +1 -1
- package/dist/data-api/platformOwner/invitations.d.ts +22 -0
- package/dist/data-api/platformOwner/invitations.js +121 -0
- package/dist/data-api/platformOwner/invitations.js.map +1 -0
- package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
- package/dist/data-api/platformOwner/workspaces.js +64 -12
- package/dist/data-api/platformOwner/workspaces.js.map +1 -1
- package/dist/data-api/tenant/tenant-invoices.js +2 -10
- package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
- package/dist/hooks/register.js +2 -1
- package/dist/hooks/register.js.map +1 -1
- package/dist/implementations/dms-saas/billing.d.ts +2 -1
- package/dist/implementations/dms-saas/billing.js +6 -0
- package/dist/implementations/dms-saas/billing.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/invoice-line-items/injection.d.ts +10 -0
- package/dist/invoice-line-items/injection.js +7 -3
- package/dist/invoice-line-items/injection.js.map +1 -1
- package/dist/notifications/subjects.d.ts +19 -19
- package/dist/operator-actions/index.d.ts +1 -0
- package/dist/operator-actions/index.js +1 -0
- package/dist/operator-actions/index.js.map +1 -1
- package/dist/operator-actions/invitation-commands.d.ts +12 -0
- package/dist/operator-actions/invitation-commands.js +46 -0
- package/dist/operator-actions/invitation-commands.js.map +1 -0
- package/dist/operator-actions/types.d.ts +1 -1
- package/dist/pages/platform/categories.d.ts +6 -6
- package/dist/pages/platform/credit-notes.d.ts +1 -1
- package/dist/pages/platform/dashboard.d.ts +2 -2
- package/dist/pages/platform/dashboard.js +2 -4
- package/dist/pages/platform/dashboard.js.map +1 -1
- package/dist/pages/platform/features.d.ts +1 -1
- package/dist/pages/platform/invoices.d.ts +1 -1
- package/dist/pages/platform/plans/edit.d.ts +2 -2
- package/dist/pages/platform/plans/index.d.ts +1 -1
- package/dist/pages/platform/plans/migrations.d.ts +1 -1
- package/dist/pages/platform/segments/index.d.ts +1 -1
- package/dist/pages/platform/settings.d.ts +1 -1
- package/dist/pages/platform/users/detail.d.ts +1 -1
- package/dist/pages/platform/users/index.d.ts +1 -1
- package/dist/pages/platform/workspaces/detail.d.ts +1 -1
- package/dist/pages/platform/workspaces/detail.js +40 -1
- package/dist/pages/platform/workspaces/detail.js.map +1 -1
- package/dist/pages/platform/workspaces/index.d.ts +4 -10
- package/dist/pages/platform/workspaces/index.js +27 -111
- package/dist/pages/platform/workspaces/index.js.map +1 -1
- package/dist/pages/public/legal/privacy-policy.js +2 -1
- package/dist/pages/public/legal/privacy-policy.js.map +1 -1
- package/dist/pages/public/legal/terms-and-conditions.js +2 -1
- package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
- package/dist/pages/public/legal/terms-of-use.js +2 -1
- package/dist/pages/public/legal/terms-of-use.js.map +1 -1
- package/dist/pages/tenant/billing.d.ts +1 -1
- package/dist/plans/index.d.ts +1 -0
- package/dist/plans/index.js +1 -0
- package/dist/plans/index.js.map +1 -1
- package/dist/plans/platform-support.d.ts +2 -0
- package/dist/plans/platform-support.js +19 -0
- package/dist/plans/platform-support.js.map +1 -0
- package/dist/plans/seat-capacity.d.ts +9 -2
- package/dist/plans/seat-capacity.js +59 -12
- package/dist/plans/seat-capacity.js.map +1 -1
- package/dist/plans/seat-hooks.js +34 -13
- package/dist/plans/seat-hooks.js.map +1 -1
- package/dist/routes/platformOwner/billing-settings.d.ts +2 -2
- package/dist/routes/platformOwner/dashboard.d.ts +8 -6
- package/dist/routes/platformOwner/dashboard.js +31 -17
- package/dist/routes/platformOwner/dashboard.js.map +1 -1
- package/dist/routes/platformOwner/index.d.ts +1 -0
- package/dist/routes/platformOwner/index.js +1 -0
- package/dist/routes/platformOwner/index.js.map +1 -1
- package/dist/routes/platformOwner/plan-deletion.d.ts +1 -1
- package/dist/routes/platformOwner/plans.d.ts +5 -5
- package/dist/routes/platformOwner/segments.d.ts +1 -1
- package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
- package/dist/routes/platformOwner/workspace-invitations.js +73 -0
- package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
- package/dist/routes/public/legal-documents.d.ts +2 -2
- package/dist/routes/public/register.d.ts +7 -3
- package/dist/routes/public/register.js +42 -27
- package/dist/routes/public/register.js.map +1 -1
- package/dist/routes/tenant/tenant-billing.d.ts +7 -11
- package/dist/routes/tenant/tenant-billing.js +34 -26
- package/dist/routes/tenant/tenant-billing.js.map +1 -1
- package/dist/routes/tenant/tenant-plan.d.ts +1 -0
- package/dist/routes/tenant/tenant-plan.js +27 -2
- package/dist/routes/tenant/tenant-plan.js.map +1 -1
- package/dist/routes/tenant/tenant-seats.d.ts +2 -4
- package/dist/routes/tenant/tenant-seats.js.map +1 -1
- package/dist/routes/tenant/workspaces.d.ts +3 -1
- package/dist/routes/tenant/workspaces.js +10 -4
- package/dist/routes/tenant/workspaces.js.map +1 -1
- package/dist/stripe/upcoming-invoice.d.ts +21 -0
- package/dist/stripe/upcoming-invoice.js +42 -0
- package/dist/stripe/upcoming-invoice.js.map +1 -0
- package/dist/stripe/webhook-dispatch.js +2 -0
- package/dist/stripe/webhook-dispatch.js.map +1 -1
- package/dist/types/config.d.ts +9 -0
- package/dist/types/config.js +7 -0
- package/dist/types/config.js.map +1 -1
- package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
- package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
- package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
- package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
- package/dist/upcoming-invoice/eligibility.d.ts +17 -0
- package/dist/upcoming-invoice/eligibility.js +52 -0
- package/dist/upcoming-invoice/eligibility.js.map +1 -0
- package/dist/upcoming-invoice/invalidation.d.ts +2 -0
- package/dist/upcoming-invoice/invalidation.js +39 -0
- package/dist/upcoming-invoice/invalidation.js.map +1 -0
- package/dist/upcoming-invoice/mapping.d.ts +8 -0
- package/dist/upcoming-invoice/mapping.js +82 -0
- package/dist/upcoming-invoice/mapping.js.map +1 -0
- package/dist/upcoming-invoice/outcomes.d.ts +13 -0
- package/dist/upcoming-invoice/outcomes.js +15 -0
- package/dist/upcoming-invoice/outcomes.js.map +1 -0
- package/dist/upcoming-invoice/preview.d.ts +3 -0
- package/dist/upcoming-invoice/preview.js +80 -0
- package/dist/upcoming-invoice/preview.js.map +1 -0
- package/dist/upcoming-invoice/pricing.d.ts +3 -0
- package/dist/upcoming-invoice/pricing.js +84 -0
- package/dist/upcoming-invoice/pricing.js.map +1 -0
- package/dist/upcoming-invoice/usage.d.ts +14 -0
- package/dist/upcoming-invoice/usage.js +53 -0
- package/dist/upcoming-invoice/usage.js.map +1 -0
- package/dist/utils/invoice-lines-type.d.ts +1 -1
- package/dist/workspaces/billing-identity.d.ts +19 -0
- package/dist/workspaces/billing-identity.js +51 -0
- package/dist/workspaces/billing-identity.js.map +1 -0
- package/dist/workspaces/default-plan.d.ts +5 -0
- package/dist/workspaces/default-plan.js +109 -0
- package/dist/workspaces/default-plan.js.map +1 -0
- package/dist/workspaces/index.d.ts +2 -0
- package/dist/workspaces/index.js +2 -0
- package/dist/workspaces/index.js.map +1 -1
- package/dist/workspaces/invitations.d.ts +26 -0
- package/dist/workspaces/invitations.js +112 -0
- package/dist/workspaces/invitations.js.map +1 -0
- package/dist/workspaces/provisioning.d.ts +3 -3
- package/dist/workspaces/provisioning.js +39 -11
- package/dist/workspaces/provisioning.js.map +1 -1
- package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
- package/frontend-vue/app/components/PastDueBanner.vue +81 -0
- package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
- package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
- package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
- package/frontend-vue/app/components/PlatformNotes.vue +4 -0
- package/frontend-vue/app/components/RefundButton.vue +10 -1
- package/frontend-vue/app/components/Register.vue +49 -92
- package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
- package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
- package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
- package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
- package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
- package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
- package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
- package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
- package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
- package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
- package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
- package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
- package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
- package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
- package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
- package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
- package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
- package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
- package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
- package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
- package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
- package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
- package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
- package/frontend-vue/app/plugins/auth-links.ts +0 -4
- package/frontend-vue/app/types/runtime-config.ts +13 -0
- package/frontend-vue/dms.frontend.ts +10 -4
- package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
- package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
- package/frontend-vue/package.json +0 -1
- package/frontend-vue/pnpm-lock.yaml +1 -125
- package/package.json +15 -15
- package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
|
|
4
|
+
type VatBadgeColor = "success" | "warning";
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
/** Fields flagged after a submit attempt. */
|
|
8
|
+
invalidFields?: BillingIdentityField[];
|
|
9
|
+
vatVerificationStatus?: VatVerificationStatus | null;
|
|
10
|
+
isLocked?: boolean;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const draft = defineModel<BillingIdentityDraft>({ required: true });
|
|
14
|
+
|
|
15
|
+
const CUSTOMER_TYPES: CustomerType[] = ["business", "individual"];
|
|
16
|
+
const VAT_BADGE_COLORS: Record<VatVerificationStatus, VatBadgeColor> = {
|
|
17
|
+
verified: "success",
|
|
18
|
+
pending: "warning",
|
|
19
|
+
unverified: "warning",
|
|
20
|
+
};
|
|
21
|
+
const FIELD_KEY_PREFIX = "saas.workspace.billing.identity";
|
|
22
|
+
|
|
23
|
+
const { countryItems } = useBillingCountries();
|
|
24
|
+
const { t } = useI18n();
|
|
25
|
+
|
|
26
|
+
const isBusiness = computed(() => draft.value.customerType === "business");
|
|
27
|
+
|
|
28
|
+
function fieldError(field: BillingIdentityField): string | undefined {
|
|
29
|
+
return props.invalidFields?.includes(field)
|
|
30
|
+
? t(`${FIELD_KEY_PREFIX}.errors.${field}`)
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function selectCustomerType(customerType: CustomerType): void {
|
|
35
|
+
if (props.isLocked) return;
|
|
36
|
+
draft.value = { ...draft.value, customerType };
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="flex flex-col gap-4">
|
|
42
|
+
<UFormField
|
|
43
|
+
:label="$t('saas.register.customer_type.label')"
|
|
44
|
+
:error="fieldError('customerType')"
|
|
45
|
+
>
|
|
46
|
+
<div
|
|
47
|
+
role="radiogroup"
|
|
48
|
+
:aria-label="$t('saas.register.customer_type.label')"
|
|
49
|
+
class="grid gap-2 sm:grid-cols-2"
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
v-for="customerType in CUSTOMER_TYPES"
|
|
53
|
+
:key="customerType"
|
|
54
|
+
type="button"
|
|
55
|
+
role="radio"
|
|
56
|
+
:aria-checked="draft.customerType === customerType"
|
|
57
|
+
:disabled="isLocked"
|
|
58
|
+
class="rounded-lg border p-3 text-left transition-colors disabled:cursor-default"
|
|
59
|
+
:class="
|
|
60
|
+
draft.customerType === customerType
|
|
61
|
+
? 'border-primary bg-primary/5'
|
|
62
|
+
: 'border-default hover:bg-elevated/50'
|
|
63
|
+
"
|
|
64
|
+
@click="selectCustomerType(customerType)"
|
|
65
|
+
>
|
|
66
|
+
<span class="block text-sm font-medium">
|
|
67
|
+
{{ $t(`saas.register.customer_type.${customerType}`) }}
|
|
68
|
+
</span>
|
|
69
|
+
<span class="text-muted block text-xs">
|
|
70
|
+
{{ $t(`${FIELD_KEY_PREFIX}.customer_type_hint.${customerType}`) }}
|
|
71
|
+
</span>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
</UFormField>
|
|
75
|
+
|
|
76
|
+
<template v-if="isBusiness">
|
|
77
|
+
<UFormField
|
|
78
|
+
:label="$t('saas.register.field.company_name')"
|
|
79
|
+
:error="fieldError('companyName')"
|
|
80
|
+
required
|
|
81
|
+
>
|
|
82
|
+
<UInput
|
|
83
|
+
v-model="draft.companyName"
|
|
84
|
+
autocomplete="organization"
|
|
85
|
+
:disabled="isLocked"
|
|
86
|
+
class="w-full"
|
|
87
|
+
/>
|
|
88
|
+
</UFormField>
|
|
89
|
+
<UFormField
|
|
90
|
+
:label="$t('saas.register.field.vat_number')"
|
|
91
|
+
:hint="$t(`${FIELD_KEY_PREFIX}.optional`)"
|
|
92
|
+
>
|
|
93
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
94
|
+
<UInput
|
|
95
|
+
v-model="draft.vatNumber"
|
|
96
|
+
:disabled="isLocked"
|
|
97
|
+
class="min-w-40 grow"
|
|
98
|
+
/>
|
|
99
|
+
<UBadge
|
|
100
|
+
v-if="vatVerificationStatus"
|
|
101
|
+
:color="VAT_BADGE_COLORS[vatVerificationStatus]"
|
|
102
|
+
variant="subtle"
|
|
103
|
+
>
|
|
104
|
+
{{ $t(`saas.workspace.billing.vat.${vatVerificationStatus}`) }}
|
|
105
|
+
</UBadge>
|
|
106
|
+
</div>
|
|
107
|
+
</UFormField>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<h4 class="font-semibold">{{ $t("saas.register.billing_address") }}</h4>
|
|
111
|
+
<UFormField
|
|
112
|
+
:label="$t('saas.register.field.country')"
|
|
113
|
+
:error="fieldError('country')"
|
|
114
|
+
required
|
|
115
|
+
>
|
|
116
|
+
<USelectMenu
|
|
117
|
+
v-model="draft.country"
|
|
118
|
+
:items="countryItems"
|
|
119
|
+
value-key="value"
|
|
120
|
+
:disabled="isLocked"
|
|
121
|
+
:placeholder="$t(`${FIELD_KEY_PREFIX}.country_placeholder`)"
|
|
122
|
+
class="w-full"
|
|
123
|
+
/>
|
|
124
|
+
</UFormField>
|
|
125
|
+
<UFormField
|
|
126
|
+
:label="$t('saas.register.field.address_line1')"
|
|
127
|
+
:error="fieldError('line1')"
|
|
128
|
+
required
|
|
129
|
+
>
|
|
130
|
+
<UInput
|
|
131
|
+
v-model="draft.line1"
|
|
132
|
+
autocomplete="address-line1"
|
|
133
|
+
:disabled="isLocked"
|
|
134
|
+
class="w-full"
|
|
135
|
+
/>
|
|
136
|
+
</UFormField>
|
|
137
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
138
|
+
<UFormField
|
|
139
|
+
:label="$t('saas.register.field.postal_code')"
|
|
140
|
+
:error="fieldError('postalCode')"
|
|
141
|
+
required
|
|
142
|
+
>
|
|
143
|
+
<UInput
|
|
144
|
+
v-model="draft.postalCode"
|
|
145
|
+
autocomplete="postal-code"
|
|
146
|
+
:disabled="isLocked"
|
|
147
|
+
class="w-full"
|
|
148
|
+
/>
|
|
149
|
+
</UFormField>
|
|
150
|
+
<UFormField
|
|
151
|
+
:label="$t('saas.register.field.city')"
|
|
152
|
+
:error="fieldError('city')"
|
|
153
|
+
required
|
|
154
|
+
>
|
|
155
|
+
<UInput
|
|
156
|
+
v-model="draft.city"
|
|
157
|
+
autocomplete="address-level2"
|
|
158
|
+
:disabled="isLocked"
|
|
159
|
+
class="w-full"
|
|
160
|
+
/>
|
|
161
|
+
</UFormField>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<UFormField
|
|
165
|
+
:label="$t('saas.workspace.billing.billing_email.label')"
|
|
166
|
+
:description="$t('saas.workspace.billing.billing_email.hint')"
|
|
167
|
+
:error="fieldError('billingEmail')"
|
|
168
|
+
required
|
|
169
|
+
>
|
|
170
|
+
<UInput
|
|
171
|
+
v-model="draft.billingEmail"
|
|
172
|
+
type="email"
|
|
173
|
+
autocomplete="email"
|
|
174
|
+
:disabled="isLocked"
|
|
175
|
+
class="w-full"
|
|
176
|
+
/>
|
|
177
|
+
</UFormField>
|
|
178
|
+
</div>
|
|
179
|
+
</template>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted } from "vue";
|
|
3
|
+
|
|
4
|
+
const KEY_PREFIX = "saas.workspace.billing.past_due.banner";
|
|
5
|
+
const BILLING_PATH = "/settings/workspace/billing";
|
|
6
|
+
const DEADLINE_FORMAT: Intl.DateTimeFormatOptions = {
|
|
7
|
+
day: "numeric",
|
|
8
|
+
month: "long",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Rendered as the content of the DMS layout banner registered server-side
|
|
12
|
+
// (see registerPastDueBanner), which already decided it shows and draws the
|
|
13
|
+
// alert chrome, icon and role around it.
|
|
14
|
+
const { data, load } = useBillingStatus();
|
|
15
|
+
const { formatMinorUnits } = useMoneyFormat();
|
|
16
|
+
const { t, locale } = useI18n();
|
|
17
|
+
|
|
18
|
+
// Owner-only in the API payload: a member gets the generic line and no
|
|
19
|
+
// settle button, the owner pays.
|
|
20
|
+
const invoice = computed(() => data.value?.unpaidInvoice ?? null);
|
|
21
|
+
const action = computed(() => resolvePastDueBannerAction(data.value));
|
|
22
|
+
|
|
23
|
+
// Without the invoice details the owner still has to be told where to act,
|
|
24
|
+
// while a member is only told who does.
|
|
25
|
+
const genericMessage = computed(() =>
|
|
26
|
+
data.value?.isTenantOwner
|
|
27
|
+
? t(`${KEY_PREFIX}.generic_owner`)
|
|
28
|
+
: t(`${KEY_PREFIX}.generic`),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const message = computed(() => {
|
|
32
|
+
if (!invoice.value) return genericMessage.value;
|
|
33
|
+
const amount = formatMinorUnits(invoice.value.amount, invoice.value.currency);
|
|
34
|
+
const subject = { invoice: invoice.value.number ?? amount, amount };
|
|
35
|
+
const suspendOn = formatDate(
|
|
36
|
+
invoice.value.suspendAt,
|
|
37
|
+
locale.value,
|
|
38
|
+
DEADLINE_FORMAT,
|
|
39
|
+
);
|
|
40
|
+
return suspendOn
|
|
41
|
+
? t(`${KEY_PREFIX}.with_deadline`, { ...subject, date: suspendOn })
|
|
42
|
+
: t(`${KEY_PREFIX}.summary`, subject);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function settle(): void {
|
|
46
|
+
const url = invoice.value?.hostedInvoiceUrl;
|
|
47
|
+
if (!url || typeof window === "undefined") return;
|
|
48
|
+
window.open(url, "_blank", "noopener");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onMounted(load);
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<div
|
|
56
|
+
data-saas-past-due-banner
|
|
57
|
+
class="flex flex-wrap items-center gap-x-3 gap-y-2"
|
|
58
|
+
>
|
|
59
|
+
<p class="min-w-0 grow">{{ message }}</p>
|
|
60
|
+
<UButton
|
|
61
|
+
v-if="action === 'settle'"
|
|
62
|
+
size="sm"
|
|
63
|
+
color="error"
|
|
64
|
+
variant="outline"
|
|
65
|
+
trailing-icon="i-ph-arrow-square-out"
|
|
66
|
+
@click="settle"
|
|
67
|
+
>
|
|
68
|
+
{{ $t(`${KEY_PREFIX}.settle`) }}
|
|
69
|
+
</UButton>
|
|
70
|
+
<UButton
|
|
71
|
+
v-else-if="action === 'open_billing'"
|
|
72
|
+
size="sm"
|
|
73
|
+
color="error"
|
|
74
|
+
variant="outline"
|
|
75
|
+
trailing-icon="i-ph-arrow-right"
|
|
76
|
+
:to="BILLING_PATH"
|
|
77
|
+
>
|
|
78
|
+
{{ $t(`${KEY_PREFIX}.open_billing`) }}
|
|
79
|
+
</UButton>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
@@ -1,31 +1,51 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted } from "vue";
|
|
3
3
|
|
|
4
|
+
type BadgeColor = "success" | "warning" | "neutral";
|
|
5
|
+
|
|
6
|
+
interface PaymentMethodBadge {
|
|
7
|
+
color: BadgeColor;
|
|
8
|
+
key: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
const PAST_DUE_STATUS = "past_due";
|
|
12
|
+
const FREE_PLAN_PRICE = 0;
|
|
5
13
|
const EXPIRY_YEAR_MODULO = 100;
|
|
6
14
|
const EXPIRY_PAD_LENGTH = 2;
|
|
15
|
+
const KEY_PREFIX = "saas.workspace.billing.payment_method";
|
|
7
16
|
|
|
8
17
|
const { data, error, load, refresh } = useBillingStatus();
|
|
18
|
+
const { data: plan, load: loadPlan } = useTenantPlan();
|
|
19
|
+
const { open: openPlanComparison } = usePlanComparison();
|
|
9
20
|
|
|
10
21
|
const card = computed(() => data.value?.paymentMethod ?? null);
|
|
11
22
|
const needsUpdate = computed(() => data.value?.status === PAST_DUE_STATUS);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
const isFreePlan = computed(
|
|
24
|
+
() => (plan.value?.current?.price ?? FREE_PLAN_PRICE) <= FREE_PLAN_PRICE,
|
|
25
|
+
);
|
|
26
|
+
// Card details are owner-only in the API payload: a member is told who
|
|
27
|
+
// manages the card rather than shown a misleading "no card".
|
|
28
|
+
const isTenantOwner = computed(() => !!data.value?.isTenantOwner);
|
|
29
|
+
const hasStripeCustomer = computed(() => !!data.value?.hasStripeCustomer);
|
|
30
|
+
|
|
31
|
+
// "You'll be asked for a card on upgrade" is only true without a Stripe
|
|
32
|
+
// customer: once one exists, a missing card is added through the portal.
|
|
33
|
+
const noCardKey = computed(() =>
|
|
34
|
+
hasStripeCustomer.value ? "none_with_portal" : "none",
|
|
16
35
|
);
|
|
17
36
|
|
|
18
|
-
const headerBadge = computed(() => {
|
|
19
|
-
if (!card.value) return { color: "warning"
|
|
20
|
-
if (needsUpdate.value) return { color: "warning"
|
|
21
|
-
return { color: "success"
|
|
37
|
+
const headerBadge = computed<PaymentMethodBadge>(() => {
|
|
38
|
+
if (!card.value) return { color: "warning", key: "missing" };
|
|
39
|
+
if (needsUpdate.value) return { color: "warning", key: "needs_update" };
|
|
40
|
+
if (isFreePlan.value) return { color: "success", key: "verified" };
|
|
41
|
+
return { color: "success", key: "active" };
|
|
22
42
|
});
|
|
23
43
|
|
|
24
|
-
const cardBadge = computed(() =>
|
|
25
|
-
needsUpdate.value
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
);
|
|
44
|
+
const cardBadge = computed<PaymentMethodBadge>(() => {
|
|
45
|
+
if (needsUpdate.value) return { color: "warning", key: "declined" };
|
|
46
|
+
if (isFreePlan.value) return { color: "success", key: "never_charged" };
|
|
47
|
+
return { color: "neutral", key: "default" };
|
|
48
|
+
});
|
|
29
49
|
|
|
30
50
|
const expiryLabel = computed(() => {
|
|
31
51
|
if (!card.value) return "";
|
|
@@ -37,48 +57,84 @@ const expiryLabel = computed(() => {
|
|
|
37
57
|
return `${month}/${year}`;
|
|
38
58
|
});
|
|
39
59
|
|
|
40
|
-
onMounted(
|
|
60
|
+
onMounted(() => {
|
|
61
|
+
void loadPlan();
|
|
62
|
+
return load();
|
|
63
|
+
});
|
|
41
64
|
</script>
|
|
42
65
|
|
|
43
66
|
<template>
|
|
44
67
|
<DmsSaasLoadFailure v-if="error && !data" @retry="refresh" />
|
|
45
|
-
<UCard v-else-if="
|
|
68
|
+
<UCard v-else-if="data">
|
|
46
69
|
<template #header>
|
|
47
|
-
<div class="flex flex-wrap items-center gap-2">
|
|
48
|
-
<h3 class="font-semibold">
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
71
|
+
<h3 class="font-semibold">{{ $t(`${KEY_PREFIX}.title`) }}</h3>
|
|
72
|
+
<UBadge
|
|
73
|
+
v-if="isTenantOwner"
|
|
74
|
+
:color="headerBadge.color"
|
|
75
|
+
variant="subtle"
|
|
76
|
+
>
|
|
77
|
+
{{ $t(`${KEY_PREFIX}.${headerBadge.key}`) }}
|
|
53
78
|
</UBadge>
|
|
54
79
|
</div>
|
|
55
80
|
</template>
|
|
56
81
|
|
|
57
|
-
<
|
|
82
|
+
<p v-if="!isTenantOwner" class="text-muted text-sm">
|
|
83
|
+
{{ $t(`${KEY_PREFIX}.owner_only`) }}
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
<div v-else class="flex flex-col gap-4">
|
|
87
|
+
<template v-if="card">
|
|
88
|
+
<div
|
|
89
|
+
class="border-default flex flex-wrap items-center gap-3 rounded-lg border p-3"
|
|
90
|
+
>
|
|
91
|
+
<UIcon name="i-ph-credit-card" class="text-muted size-5" />
|
|
92
|
+
<span class="font-medium capitalize">
|
|
93
|
+
{{ card.brand }} •••• {{ card.last4 }}
|
|
94
|
+
</span>
|
|
95
|
+
<span class="text-muted text-sm">
|
|
96
|
+
{{ $t(`${KEY_PREFIX}.expires`, { date: expiryLabel }) }}
|
|
97
|
+
</span>
|
|
98
|
+
<span class="grow" />
|
|
99
|
+
<UBadge :color="cardBadge.color" variant="subtle">
|
|
100
|
+
{{ $t(`${KEY_PREFIX}.${cardBadge.key}`) }}
|
|
101
|
+
</UBadge>
|
|
102
|
+
</div>
|
|
103
|
+
<p v-if="needsUpdate" class="text-muted text-sm">
|
|
104
|
+
{{ $t(`${KEY_PREFIX}.update_note`) }}
|
|
105
|
+
</p>
|
|
106
|
+
<p v-else-if="isFreePlan" class="text-muted text-sm">
|
|
107
|
+
{{ $t(`${KEY_PREFIX}.free_note`) }}
|
|
108
|
+
</p>
|
|
109
|
+
</template>
|
|
110
|
+
|
|
58
111
|
<div
|
|
59
|
-
v-
|
|
60
|
-
class="border-default flex flex-wrap items-center gap-3 rounded-lg border p-3"
|
|
112
|
+
v-else
|
|
113
|
+
class="border-default flex flex-wrap items-center gap-3 rounded-lg border border-dashed p-3"
|
|
61
114
|
>
|
|
62
115
|
<UIcon name="i-ph-credit-card" class="text-muted size-5" />
|
|
63
|
-
<span class="
|
|
64
|
-
{{
|
|
65
|
-
</span>
|
|
66
|
-
<span class="text-muted text-sm">
|
|
67
|
-
{{ $t("saas.workspace.billing.payment_method.expires", { date: expiryLabel }) }}
|
|
116
|
+
<span class="text-muted grow text-sm">
|
|
117
|
+
{{ $t(`${KEY_PREFIX}.${noCardKey}`) }}
|
|
68
118
|
</span>
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
119
|
+
<UButton
|
|
120
|
+
v-if="!hasStripeCustomer"
|
|
121
|
+
size="sm"
|
|
122
|
+
color="primary"
|
|
123
|
+
variant="subtle"
|
|
124
|
+
icon="i-ph-plus"
|
|
125
|
+
@click="openPlanComparison"
|
|
126
|
+
>
|
|
127
|
+
{{ $t(`${KEY_PREFIX}.add`) }}
|
|
128
|
+
</UButton>
|
|
73
129
|
</div>
|
|
74
|
-
<p v-else class="text-muted text-sm">
|
|
75
|
-
{{ $t("saas.workspace.billing.payment_method.none") }}
|
|
76
|
-
</p>
|
|
77
130
|
|
|
78
|
-
<div
|
|
131
|
+
<div
|
|
132
|
+
v-if="hasStripeCustomer"
|
|
133
|
+
class="border-default flex flex-wrap items-center gap-3 border-t pt-4"
|
|
134
|
+
>
|
|
79
135
|
<DmsSaasCustomerPortalButton />
|
|
80
136
|
<p class="text-muted grow text-xs">
|
|
81
|
-
{{ $t(
|
|
137
|
+
{{ $t(`${KEY_PREFIX}.portal_hint`) }}
|
|
82
138
|
</p>
|
|
83
139
|
</div>
|
|
84
140
|
</div>
|
|
@@ -7,9 +7,12 @@ const props = defineProps<{
|
|
|
7
7
|
currentPlanId: string | null;
|
|
8
8
|
pendingPlanId: string | null;
|
|
9
9
|
isRecovery?: boolean;
|
|
10
|
+
/** A paid plan chosen from a free one goes through the upgrade flow. */
|
|
11
|
+
isCurrentPaid?: boolean;
|
|
12
|
+
workspaceName?: string | null;
|
|
10
13
|
}>();
|
|
11
14
|
|
|
12
|
-
const emit = defineEmits<{ changed: [] }>();
|
|
15
|
+
const emit = defineEmits<{ changed: []; upgrade: [plan: TenantPlanView] }>();
|
|
13
16
|
|
|
14
17
|
const open = defineModel<boolean>("open", { default: false });
|
|
15
18
|
|
|
@@ -18,20 +21,40 @@ const toast = useToast();
|
|
|
18
21
|
const { resolveApiError } = useApiErrorMessage();
|
|
19
22
|
const { changePlan } = useTenantPlan();
|
|
20
23
|
const { formatFeatureValue } = usePlanFeatureFormat();
|
|
24
|
+
const { featureLabel, featureTooltip } = usePlanFeatureLabel();
|
|
21
25
|
const { formatMajorUnits } = useMoneyFormat();
|
|
22
26
|
const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
|
|
23
27
|
|
|
24
28
|
const showDetailRows = ref(false);
|
|
25
29
|
const pendingPlanRequest = ref<string | null>(null);
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
/** A feature no compared plan sets would only render a row of dashes. */
|
|
32
|
+
const comparedFeatures = computed(() =>
|
|
33
|
+
props.features.filter((feature) =>
|
|
34
|
+
props.plans.some(
|
|
35
|
+
(plan) => plan.featureValues[feature.featureId] !== undefined,
|
|
36
|
+
),
|
|
30
37
|
),
|
|
31
38
|
);
|
|
32
39
|
|
|
40
|
+
interface FeatureRow {
|
|
41
|
+
feature: TenantPlanFeature;
|
|
42
|
+
label: string;
|
|
43
|
+
tooltip: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const visibleRows = computed<FeatureRow[]>(() =>
|
|
47
|
+
comparedFeatures.value
|
|
48
|
+
.filter((feature) => showDetailRows.value || !feature.isDetailRow)
|
|
49
|
+
.map((feature) => ({
|
|
50
|
+
feature,
|
|
51
|
+
label: featureLabel(feature),
|
|
52
|
+
tooltip: featureTooltip(feature),
|
|
53
|
+
})),
|
|
54
|
+
);
|
|
55
|
+
|
|
33
56
|
const hasDetailRows = computed(() =>
|
|
34
|
-
|
|
57
|
+
comparedFeatures.value.some((feature) => feature.isDetailRow),
|
|
35
58
|
);
|
|
36
59
|
|
|
37
60
|
const currentPlan = computed(
|
|
@@ -46,7 +69,22 @@ function isCurrent(plan: TenantPlanView): boolean {
|
|
|
46
69
|
return plan._id === props.currentPlanId;
|
|
47
70
|
}
|
|
48
71
|
|
|
72
|
+
/** The paid plans are the way forward from a free one or a recovery. */
|
|
73
|
+
function isHighlighted(plan: TenantPlanView): boolean {
|
|
74
|
+
const isWayForward = props.isRecovery || !props.isCurrentPaid;
|
|
75
|
+
return isWayForward && plan.checkoutAvailable;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function needsUpgradeFlow(plan: TenantPlanView): boolean {
|
|
79
|
+
return !props.isCurrentPaid && plan.price > 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
49
82
|
async function select(plan: TenantPlanView): Promise<void> {
|
|
83
|
+
if (needsUpgradeFlow(plan)) {
|
|
84
|
+
open.value = false;
|
|
85
|
+
emit("upgrade", plan);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
50
88
|
pendingPlanRequest.value = plan._id;
|
|
51
89
|
try {
|
|
52
90
|
const result = await changePlan(plan._id);
|
|
@@ -82,6 +120,7 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
82
120
|
:description="
|
|
83
121
|
currentPlan
|
|
84
122
|
? $t('saas.workspace.plan.comparison.subtitle', {
|
|
123
|
+
workspace: workspaceName ?? '',
|
|
85
124
|
plan: currentPlan.name,
|
|
86
125
|
})
|
|
87
126
|
: $t('saas.workspace.plan.comparison.subtitle_no_plan')
|
|
@@ -91,7 +130,7 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
91
130
|
<template #body>
|
|
92
131
|
<div class="flex flex-col gap-4">
|
|
93
132
|
<div class="overflow-x-auto">
|
|
94
|
-
<table class="
|
|
133
|
+
<table class="min-w-3xl w-full border-collapse text-sm">
|
|
95
134
|
<thead>
|
|
96
135
|
<tr>
|
|
97
136
|
<th class="w-56" />
|
|
@@ -100,11 +139,11 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
100
139
|
:key="plan._id"
|
|
101
140
|
scope="col"
|
|
102
141
|
class="border-default border-b p-3 text-center align-top"
|
|
103
|
-
:class="isCurrent(plan) ? 'bg-
|
|
142
|
+
:class="isCurrent(plan) ? 'bg-primary/10 rounded-t-lg' : ''"
|
|
104
143
|
>
|
|
105
144
|
<div class="flex flex-col items-center gap-2">
|
|
106
145
|
<span class="font-semibold">{{ plan.name }}</span>
|
|
107
|
-
<span class="text-
|
|
146
|
+
<span class="text-primary font-semibold tabular-nums">
|
|
108
147
|
{{ priceLabel(plan) }}
|
|
109
148
|
<span class="text-xs">
|
|
110
149
|
/{{ planIntervalLabel(plan.interval) }}
|
|
@@ -127,16 +166,8 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
127
166
|
<UButton
|
|
128
167
|
v-else
|
|
129
168
|
size="xs"
|
|
130
|
-
:color="
|
|
131
|
-
|
|
132
|
-
? 'primary'
|
|
133
|
-
: 'neutral'
|
|
134
|
-
"
|
|
135
|
-
:variant="
|
|
136
|
-
isRecovery && plan.checkoutAvailable
|
|
137
|
-
? 'solid'
|
|
138
|
-
: 'subtle'
|
|
139
|
-
"
|
|
169
|
+
:color="isHighlighted(plan) ? 'primary' : 'neutral'"
|
|
170
|
+
:variant="isHighlighted(plan) ? 'solid' : 'subtle'"
|
|
140
171
|
:loading="pendingPlanRequest === plan._id"
|
|
141
172
|
:disabled="
|
|
142
173
|
pendingPlanRequest !== null ||
|
|
@@ -152,20 +183,20 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
152
183
|
</thead>
|
|
153
184
|
<tbody>
|
|
154
185
|
<tr
|
|
155
|
-
v-for="feature in
|
|
186
|
+
v-for="{ feature, label, tooltip } in visibleRows"
|
|
156
187
|
:key="feature.featureId"
|
|
157
188
|
class="border-default border-b last:border-0"
|
|
158
189
|
>
|
|
159
190
|
<th scope="row" class="p-3 text-left font-normal">
|
|
160
191
|
<span class="inline-flex items-center gap-1">
|
|
161
|
-
{{
|
|
162
|
-
<UTooltip v-if="
|
|
192
|
+
{{ label }}
|
|
193
|
+
<UTooltip v-if="tooltip" :text="tooltip">
|
|
163
194
|
<UButton
|
|
164
195
|
variant="link"
|
|
165
196
|
color="neutral"
|
|
166
197
|
size="xs"
|
|
167
198
|
icon="i-ph-info"
|
|
168
|
-
:aria-label="
|
|
199
|
+
:aria-label="tooltip"
|
|
169
200
|
class="text-muted p-0"
|
|
170
201
|
/>
|
|
171
202
|
</UTooltip>
|
|
@@ -175,12 +206,13 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
175
206
|
v-for="plan in plans"
|
|
176
207
|
:key="plan._id"
|
|
177
208
|
class="p-3 text-center tabular-nums"
|
|
178
|
-
:class="isCurrent(plan) ? 'bg-
|
|
209
|
+
:class="isCurrent(plan) ? 'bg-primary/10' : ''"
|
|
179
210
|
>
|
|
180
211
|
{{
|
|
181
212
|
formatFeatureValue(
|
|
182
213
|
feature,
|
|
183
214
|
plan.featureValues[feature.featureId],
|
|
215
|
+
plan.currency,
|
|
184
216
|
)
|
|
185
217
|
}}
|
|
186
218
|
</td>
|
|
@@ -203,10 +235,14 @@ async function select(plan: TenantPlanView): Promise<void> {
|
|
|
203
235
|
}}
|
|
204
236
|
</UButton>
|
|
205
237
|
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</template>
|
|
206
240
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
241
|
+
<template #footer>
|
|
242
|
+
<div class="flex w-full justify-end">
|
|
243
|
+
<UButton color="neutral" variant="subtle" @click="open = false">
|
|
244
|
+
{{ $t("saas.workspace.plan.comparison.close") }}
|
|
245
|
+
</UButton>
|
|
210
246
|
</div>
|
|
211
247
|
</template>
|
|
212
248
|
</UModal>
|