@antelopejs/dms-saas 0.2.0 → 0.3.0
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 +307 -33
- 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/invoice-options.js +3 -2
- package/dist/data-api/platformOwner/invoice-options.js.map +1 -1
- 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/billing-window.js +2 -2
- package/dist/invoice-line-items/billing-window.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/operator-actions/db/lifecycle-delivery.model.js +1 -3
- package/dist/operator-actions/db/lifecycle-delivery.model.js.map +1 -1
- package/dist/operator-actions/db/lifecycle-delivery.table.d.ts +1 -1
- package/dist/operator-actions/db/lifecycle-delivery.table.js.map +1 -1
- package/dist/operator-actions/db/operator-action.model.js +1 -3
- package/dist/operator-actions/db/operator-action.model.js.map +1 -1
- package/dist/operator-actions/db/operator-action.table.d.ts +1 -1
- package/dist/operator-actions/db/operator-action.table.js.map +1 -1
- 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/dashboard.js +2 -4
- package/dist/pages/platform/dashboard.js.map +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 +2 -8
- 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/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/credit-notes-issue.js +2 -1
- package/dist/routes/platformOwner/credit-notes-issue.js.map +1 -1
- 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/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/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-ops.js +2 -2
- package/dist/routes/tenant/tenant-plan-ops.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 +2 -0
- 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/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 +110 -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 +49 -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/useBillingDocumentType.ts +9 -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/plugins/billing-data-types.ts +4 -2
- 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 +5 -5
- package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const BILLING_INFO_ENDPOINT = "/api/saas/tenant/billing-info";
|
|
2
|
+
const BUSINESS_CUSTOMER_TYPE = "business";
|
|
3
|
+
const COUNTRY_CODE_PATTERN = /^[A-Z]{2}$/;
|
|
4
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
5
|
+
|
|
6
|
+
export type CustomerType = "individual" | "business";
|
|
7
|
+
export type VatVerificationStatus = "pending" | "verified" | "unverified";
|
|
8
|
+
|
|
9
|
+
export type BillingIdentityField =
|
|
10
|
+
| "customerType"
|
|
11
|
+
| "companyName"
|
|
12
|
+
| "country"
|
|
13
|
+
| "line1"
|
|
14
|
+
| "postalCode"
|
|
15
|
+
| "city"
|
|
16
|
+
| "billingEmail";
|
|
17
|
+
|
|
18
|
+
/** The editable billing identity, as the form holds it. */
|
|
19
|
+
export interface BillingIdentityDraft {
|
|
20
|
+
customerType: CustomerType | null;
|
|
21
|
+
companyName: string;
|
|
22
|
+
vatNumber: string;
|
|
23
|
+
billingEmail: string;
|
|
24
|
+
country: string;
|
|
25
|
+
line1: string;
|
|
26
|
+
postalCode: string;
|
|
27
|
+
city: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface BillingAddress {
|
|
31
|
+
line1: string | null;
|
|
32
|
+
line2: string | null;
|
|
33
|
+
postalCode: string | null;
|
|
34
|
+
city: string | null;
|
|
35
|
+
state: string | null;
|
|
36
|
+
country: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BillingInfoResponse {
|
|
40
|
+
customerType: CustomerType | null;
|
|
41
|
+
companyName: string | null;
|
|
42
|
+
vatNumber: string | null;
|
|
43
|
+
vatVerificationStatus: VatVerificationStatus | null;
|
|
44
|
+
billingEmail: string | null;
|
|
45
|
+
address: BillingAddress | null;
|
|
46
|
+
missingFields: BillingIdentityField[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type FieldCheck = (draft: BillingIdentityDraft) => boolean;
|
|
50
|
+
|
|
51
|
+
function isFilled(value: string): boolean {
|
|
52
|
+
return value.trim().length > 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Mirrors the server-side rules (src/workspaces/billing-identity.ts): the
|
|
56
|
+
// form flags a field before the request, the server refuses it regardless.
|
|
57
|
+
const FIELD_CHECKS: Record<BillingIdentityField, FieldCheck> = {
|
|
58
|
+
customerType: (draft) => draft.customerType !== null,
|
|
59
|
+
companyName: (draft) =>
|
|
60
|
+
draft.customerType !== BUSINESS_CUSTOMER_TYPE ||
|
|
61
|
+
isFilled(draft.companyName),
|
|
62
|
+
country: (draft) => COUNTRY_CODE_PATTERN.test(draft.country.trim()),
|
|
63
|
+
line1: (draft) => isFilled(draft.line1),
|
|
64
|
+
postalCode: (draft) => isFilled(draft.postalCode),
|
|
65
|
+
city: (draft) => isFilled(draft.city),
|
|
66
|
+
billingEmail: (draft) => EMAIL_PATTERN.test(draft.billingEmail.trim()),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Required fields still missing or malformed, in form order; empty when the
|
|
71
|
+
* identity can be saved. The VAT number is optional, even for a business.
|
|
72
|
+
*/
|
|
73
|
+
export function findMissingBillingFields(
|
|
74
|
+
draft: BillingIdentityDraft,
|
|
75
|
+
): BillingIdentityField[] {
|
|
76
|
+
return (Object.keys(FIELD_CHECKS) as BillingIdentityField[]).filter(
|
|
77
|
+
(field) => !FIELD_CHECKS[field](draft),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function emptyBillingIdentityDraft(): BillingIdentityDraft {
|
|
82
|
+
return {
|
|
83
|
+
customerType: null,
|
|
84
|
+
companyName: "",
|
|
85
|
+
vatNumber: "",
|
|
86
|
+
billingEmail: "",
|
|
87
|
+
country: "",
|
|
88
|
+
line1: "",
|
|
89
|
+
postalCode: "",
|
|
90
|
+
city: "",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function toBillingIdentityDraft(
|
|
95
|
+
info: BillingInfoResponse,
|
|
96
|
+
): BillingIdentityDraft {
|
|
97
|
+
return {
|
|
98
|
+
customerType: info.customerType,
|
|
99
|
+
companyName: info.companyName ?? "",
|
|
100
|
+
vatNumber: info.vatNumber ?? "",
|
|
101
|
+
billingEmail: info.billingEmail ?? "",
|
|
102
|
+
country: info.address?.country ?? "",
|
|
103
|
+
line1: info.address?.line1 ?? "",
|
|
104
|
+
postalCode: info.address?.postalCode ?? "",
|
|
105
|
+
city: info.address?.city ?? "",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toRequestBody(draft: BillingIdentityDraft) {
|
|
110
|
+
const isBusiness = draft.customerType === BUSINESS_CUSTOMER_TYPE;
|
|
111
|
+
return {
|
|
112
|
+
customerType: draft.customerType,
|
|
113
|
+
companyName: isBusiness ? draft.companyName : null,
|
|
114
|
+
vatNumber: isBusiness ? draft.vatNumber : null,
|
|
115
|
+
billingEmail: draft.billingEmail,
|
|
116
|
+
address: {
|
|
117
|
+
country: draft.country,
|
|
118
|
+
line1: draft.line1,
|
|
119
|
+
postalCode: draft.postalCode,
|
|
120
|
+
city: draft.city,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reads and saves the workspace billing identity. */
|
|
126
|
+
export function useBillingIdentity() {
|
|
127
|
+
const { $authFetch } = useAuthFetch();
|
|
128
|
+
|
|
129
|
+
function load(): Promise<BillingInfoResponse> {
|
|
130
|
+
return $authFetch<BillingInfoResponse>(BILLING_INFO_ENDPOINT);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function save(draft: BillingIdentityDraft): Promise<BillingInfoResponse> {
|
|
134
|
+
return $authFetch<BillingInfoResponse>(BILLING_INFO_ENDPOINT, {
|
|
135
|
+
method: "PUT",
|
|
136
|
+
body: toRequestBody(draft),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { load, save };
|
|
141
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface InvitationLink {
|
|
2
|
+
link: string;
|
|
3
|
+
expiresAt: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface InvitationRef {
|
|
7
|
+
tenantId: string;
|
|
8
|
+
inviteId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const INVITATIONS_ENDPOINT = "/api/saas/workspaces";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Retrieves an invitation's signup link (each retrieval is journaled server
|
|
15
|
+
* side) and puts it on the clipboard, reporting the outcome with a toast.
|
|
16
|
+
*/
|
|
17
|
+
export function useInvitationLink() {
|
|
18
|
+
const { $authFetch } = useAuthFetch();
|
|
19
|
+
const nuxtApp = useDmsApp();
|
|
20
|
+
const toast = useToast();
|
|
21
|
+
const { resolveApiError } = useApiErrorMessage();
|
|
22
|
+
|
|
23
|
+
function fetchInvitationLink(ref: InvitationRef): Promise<InvitationLink> {
|
|
24
|
+
return $authFetch<InvitationLink>(
|
|
25
|
+
`${INVITATIONS_ENDPOINT}/${ref.tenantId}/invitations/${ref.inviteId}/link`,
|
|
26
|
+
{ method: "POST" },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Returns whether the link reached the clipboard. */
|
|
31
|
+
async function copyLink(link: string): Promise<boolean> {
|
|
32
|
+
const t = nuxtApp.$i18n.t;
|
|
33
|
+
try {
|
|
34
|
+
await navigator.clipboard.writeText(link);
|
|
35
|
+
toast.add({
|
|
36
|
+
title: t("saas.workspaces.invitations.link.copied"),
|
|
37
|
+
color: "success",
|
|
38
|
+
icon: "i-ph-check-circle",
|
|
39
|
+
});
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
// The clipboard can be refused (permissions, insecure origin): the link
|
|
43
|
+
// stays readable so it can still be copied by hand.
|
|
44
|
+
toast.add({
|
|
45
|
+
title: t("saas.workspaces.invitations.link.copy_failed"),
|
|
46
|
+
description: link,
|
|
47
|
+
color: "warning",
|
|
48
|
+
icon: "i-ph-warning",
|
|
49
|
+
duration: 0,
|
|
50
|
+
});
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function fetchAndCopy(ref: InvitationRef): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
const { link } = await fetchInvitationLink(ref);
|
|
58
|
+
await copyLink(link);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
toast.add({
|
|
61
|
+
title: resolveApiError(error, "saas.workspaces.invitations.link.error"),
|
|
62
|
+
color: "error",
|
|
63
|
+
icon: "i-ph-warning-circle",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { fetchInvitationLink, copyLink, fetchAndCopy };
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BillingStatusResponse } from "./useBillingStatus";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the past-due banner offers: the invoice itself when Stripe hands out
|
|
5
|
+
* its payment page, else the billing page to the owner, who can settle from
|
|
6
|
+
* there; a member only gets the notice, since the owner pays.
|
|
7
|
+
*/
|
|
8
|
+
export type PastDueBannerAction = "settle" | "open_billing" | "none";
|
|
9
|
+
|
|
10
|
+
type PastDueBannerStatus = Pick<
|
|
11
|
+
BillingStatusResponse,
|
|
12
|
+
"isTenantOwner" | "unpaidInvoice"
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
export function resolvePastDueBannerAction(
|
|
16
|
+
status: PastDueBannerStatus | null,
|
|
17
|
+
): PastDueBannerAction {
|
|
18
|
+
if (status?.unpaidInvoice?.hostedInvoiceUrl) return "settle";
|
|
19
|
+
return status?.isTenantOwner ? "open_billing" : "none";
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const OPEN_STATE_KEY = "saas-plan-comparison-open";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open state of the plan comparison modal hosted by the plan card, shared so
|
|
5
|
+
* other billing blocks (the payment method card's "add a card") can open it.
|
|
6
|
+
*/
|
|
7
|
+
export function usePlanComparison() {
|
|
8
|
+
const isOpen = useDmsState<boolean>(OPEN_STATE_KEY, () => false);
|
|
9
|
+
|
|
10
|
+
function open(): void {
|
|
11
|
+
isOpen.value = true;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return { isOpen, open };
|
|
15
|
+
}
|
|
@@ -1,59 +1,351 @@
|
|
|
1
|
+
import { formatMajorUnits } from "./useMoneyFormat";
|
|
2
|
+
|
|
1
3
|
const UNLIMITED_NUMERIC_VALUE = -1;
|
|
2
4
|
const EMPTY_LABEL = "—";
|
|
3
5
|
const INCLUDED_LABEL = "✓";
|
|
4
|
-
const EXCLUDED_LABEL = "
|
|
6
|
+
const EXCLUDED_LABEL = "—";
|
|
7
|
+
const PRICE_UNIT_PREFIX = "per ";
|
|
8
|
+
const UNIT_LABEL_KEY_PREFIX = "saas.workspace.plan.units";
|
|
9
|
+
const MAX_QUANTITY_FRACTION_DIGITS = 2;
|
|
10
|
+
const MIN_PRICE_FRACTION_DIGITS = 2;
|
|
11
|
+
const MAX_PRICE_SIGNIFICANT_DIGITS = 3;
|
|
12
|
+
/** Upper bound `Intl.NumberFormat` accepts for fraction digits. */
|
|
13
|
+
const MAX_FRACTION_DIGITS = 20;
|
|
14
|
+
const BYTES_PER_KILOBYTE = 1_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One way of showing a unit: how many stored units it holds, and the i18n
|
|
18
|
+
* key of a message node with a `quantity` message (`{value}`, pluralised on
|
|
19
|
+
* the scaled value) and a `price` message (`{price}`).
|
|
20
|
+
*/
|
|
21
|
+
export interface PlanFeatureUnitScale {
|
|
22
|
+
label: string;
|
|
23
|
+
size: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How a stored unit reads on a plan page. Quantities take the largest scale
|
|
28
|
+
* they fill; a price reads per `priceScale` of the unit.
|
|
29
|
+
*/
|
|
30
|
+
export interface PlanFeatureUnitRule {
|
|
31
|
+
scales: PlanFeatureUnitScale[];
|
|
32
|
+
priceScale: PlanFeatureUnitScale;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type UnitKind = "quantity" | "price";
|
|
36
|
+
|
|
37
|
+
/** Translates unit label keys, pluralised on `count`. */
|
|
38
|
+
export type UnitLabelTranslator = (
|
|
39
|
+
key: string,
|
|
40
|
+
params: Record<string, string>,
|
|
41
|
+
count: number,
|
|
42
|
+
) => string;
|
|
43
|
+
|
|
44
|
+
/** Everything the formatter needs besides the value, kept Vue-free for tests. */
|
|
45
|
+
export interface PlanFeatureFormatContext {
|
|
46
|
+
locale: string;
|
|
47
|
+
/** Currency the plan is billed in; prices and money amounts read in it. */
|
|
48
|
+
currency: string | null;
|
|
49
|
+
unlimitedLabel: string;
|
|
50
|
+
translateUnit: UnitLabelTranslator;
|
|
51
|
+
/**
|
|
52
|
+
* Renders a stored text value through the DMS display string convention:
|
|
53
|
+
* a `$key` is translated, anything else is returned as is.
|
|
54
|
+
*/
|
|
55
|
+
translateText: (text: string) => string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The part of a feature definition that decides how its value reads. */
|
|
59
|
+
export interface FormattableFeature {
|
|
60
|
+
valueType: string;
|
|
61
|
+
unit: string | null;
|
|
62
|
+
}
|
|
5
63
|
|
|
6
64
|
type FeatureValueFormatter = (
|
|
7
65
|
value: unknown,
|
|
8
66
|
unit: string | null,
|
|
9
|
-
|
|
67
|
+
context: PlanFeatureFormatContext,
|
|
10
68
|
) => string;
|
|
11
69
|
|
|
70
|
+
/** A built-in scale, labelled under dms-saas's own unit messages. */
|
|
71
|
+
function builtInScale(name: string, size: number): PlanFeatureUnitScale {
|
|
72
|
+
return { label: `${UNIT_LABEL_KEY_PREFIX}.${name}`, size };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BYTE_SCALES = ["byte", "kilobyte", "megabyte", "gigabyte", "terabyte"].map(
|
|
76
|
+
(name, power) => builtInScale(name, BYTES_PER_KILOBYTE ** power),
|
|
77
|
+
);
|
|
78
|
+
const GIGABYTE_SCALE = BYTE_SCALES[3]!;
|
|
79
|
+
const MINUTE_SCALE = builtInScale("minute", 1);
|
|
80
|
+
|
|
81
|
+
const BYTE_RULE: PlanFeatureUnitRule = {
|
|
82
|
+
scales: BYTE_SCALES,
|
|
83
|
+
priceScale: GIGABYTE_SCALE,
|
|
84
|
+
};
|
|
85
|
+
const MINUTE_RULE: PlanFeatureUnitRule = {
|
|
86
|
+
scales: [MINUTE_SCALE],
|
|
87
|
+
priceScale: MINUTE_SCALE,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generic units dms-saas knows, normalised to lower case. A unit missing here
|
|
92
|
+
* and from the registry still reads, as a grouped number followed by the unit
|
|
93
|
+
* verbatim.
|
|
94
|
+
*/
|
|
95
|
+
const BUILT_IN_UNIT_RULES = new Map<string, PlanFeatureUnitRule>([
|
|
96
|
+
["byte", BYTE_RULE],
|
|
97
|
+
["bytes", BYTE_RULE],
|
|
98
|
+
["minute", MINUTE_RULE],
|
|
99
|
+
["minutes", MINUTE_RULE],
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Units consumer modules registered. Registration happens at plugin setup,
|
|
104
|
+
* on the server and in the browser alike, and always maps a unit to the same
|
|
105
|
+
* rule, so one module-level map is safe to share across SSR requests.
|
|
106
|
+
*/
|
|
107
|
+
const registeredUnitRules = new Map<string, PlanFeatureUnitRule>();
|
|
108
|
+
|
|
109
|
+
function normalizeUnit(unit: string): string {
|
|
110
|
+
return unit.trim().toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Teaches the plan pages how a feature unit reads, e.g. vCPU-minutes shown
|
|
115
|
+
* in vCPU-hours. Call it from a frontend plugin of the module declaring the
|
|
116
|
+
* features; labels are full i18n keys that module ships in its own locales.
|
|
117
|
+
* A registered unit takes precedence over a built-in one of the same name.
|
|
118
|
+
*
|
|
119
|
+
* @param unit Stored unit or its spellings (matched case-insensitively), without `per `
|
|
120
|
+
* @param rule Display scales, in any order, and the scale prices read per
|
|
121
|
+
* @throws When the rule has no scale or a scale is not a positive size
|
|
122
|
+
*/
|
|
123
|
+
export function registerPlanFeatureUnit(
|
|
124
|
+
unit: string | string[],
|
|
125
|
+
rule: PlanFeatureUnitRule,
|
|
126
|
+
): void {
|
|
127
|
+
const scales = [...rule.scales].sort((left, right) => left.size - right.size);
|
|
128
|
+
const isValid =
|
|
129
|
+
scales.length > 0 &&
|
|
130
|
+
[...scales, rule.priceScale].every((candidate) => candidate.size > 0);
|
|
131
|
+
if (!isValid) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Invalid plan feature unit rule: expected at least one scale, all with a positive size",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
for (const name of [unit].flat()) {
|
|
137
|
+
registeredUnitRules.set(normalizeUnit(name), {
|
|
138
|
+
scales,
|
|
139
|
+
priceScale: rule.priceScale,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findUnitRule(base: string): PlanFeatureUnitRule | undefined {
|
|
145
|
+
return registeredUnitRules.get(base) ?? BUILT_IN_UNIT_RULES.get(base);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Units whose value is an amount of the plan's currency. */
|
|
149
|
+
const MONEY_UNITS = new Set(["currency", "currency unit", "currency units"]);
|
|
150
|
+
|
|
151
|
+
interface ParsedUnit {
|
|
152
|
+
kind: UnitKind;
|
|
153
|
+
base: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseUnit(unit: string): ParsedUnit {
|
|
157
|
+
const normalized = normalizeUnit(unit);
|
|
158
|
+
if (normalized.startsWith(PRICE_UNIT_PREFIX)) {
|
|
159
|
+
return {
|
|
160
|
+
kind: "price",
|
|
161
|
+
base: normalized.slice(PRICE_UNIT_PREFIX.length).trim(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { kind: "quantity", base: normalized };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatNumber(value: number, locale: string): string {
|
|
168
|
+
return new Intl.NumberFormat(locale, {
|
|
169
|
+
maximumFractionDigits: MAX_QUANTITY_FRACTION_DIGITS,
|
|
170
|
+
}).format(value);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fraction digits keeping `MAX_PRICE_SIGNIFICANT_DIGITS` of a price, never
|
|
175
|
+
* fewer than a currency's usual two.
|
|
176
|
+
*/
|
|
177
|
+
function priceFractionDigits(value: number): number {
|
|
178
|
+
if (value === 0) return MIN_PRICE_FRACTION_DIGITS;
|
|
179
|
+
const magnitude = Math.floor(Math.log10(Math.abs(value)));
|
|
180
|
+
const significant = MAX_PRICE_SIGNIFICANT_DIGITS - 1 - magnitude;
|
|
181
|
+
return Math.min(
|
|
182
|
+
MAX_FRACTION_DIGITS,
|
|
183
|
+
Math.max(MIN_PRICE_FRACTION_DIGITS, significant),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Unit prices are often fractions of a cent: keep three significant digits
|
|
189
|
+
* below a cent instead of rounding them to a misleading zero.
|
|
190
|
+
*/
|
|
191
|
+
function formatPrice(value: number, context: PlanFeatureFormatContext): string {
|
|
192
|
+
if (!context.currency) return formatNumber(value, context.locale);
|
|
193
|
+
return new Intl.NumberFormat(context.locale, {
|
|
194
|
+
style: "currency",
|
|
195
|
+
currency: context.currency.toUpperCase(),
|
|
196
|
+
minimumFractionDigits: MIN_PRICE_FRACTION_DIGITS,
|
|
197
|
+
maximumFractionDigits: priceFractionDigits(value),
|
|
198
|
+
}).format(value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function pickScale(
|
|
202
|
+
scales: PlanFeatureUnitScale[],
|
|
203
|
+
value: number,
|
|
204
|
+
): PlanFeatureUnitScale {
|
|
205
|
+
const magnitude = Math.abs(value);
|
|
206
|
+
const fitting = scales.filter((candidate) => candidate.size <= magnitude);
|
|
207
|
+
return fitting.at(-1) ?? scales[0]!;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function unitLabelKey(label: string, kind: UnitKind): string {
|
|
211
|
+
return `${label}.${kind}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatQuantityInRule(
|
|
215
|
+
value: number,
|
|
216
|
+
rule: PlanFeatureUnitRule,
|
|
217
|
+
context: PlanFeatureFormatContext,
|
|
218
|
+
): string {
|
|
219
|
+
const chosen = pickScale(rule.scales, value);
|
|
220
|
+
const scaled = value / chosen.size;
|
|
221
|
+
return context.translateUnit(
|
|
222
|
+
unitLabelKey(chosen.label, "quantity"),
|
|
223
|
+
{ value: formatNumber(scaled, context.locale) },
|
|
224
|
+
scaled,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function formatPriceInRule(
|
|
229
|
+
value: number,
|
|
230
|
+
rule: PlanFeatureUnitRule,
|
|
231
|
+
context: PlanFeatureFormatContext,
|
|
232
|
+
): string {
|
|
233
|
+
const scaled = value * rule.priceScale.size;
|
|
234
|
+
return context.translateUnit(
|
|
235
|
+
unitLabelKey(rule.priceScale.label, "price"),
|
|
236
|
+
{ price: formatPrice(scaled, context) },
|
|
237
|
+
1,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatWithUnknownUnit(
|
|
242
|
+
value: number,
|
|
243
|
+
parsed: ParsedUnit,
|
|
244
|
+
unit: string,
|
|
245
|
+
context: PlanFeatureFormatContext,
|
|
246
|
+
): string {
|
|
247
|
+
const amount =
|
|
248
|
+
parsed.kind === "price"
|
|
249
|
+
? formatPrice(value, context)
|
|
250
|
+
: formatNumber(value, context.locale);
|
|
251
|
+
return `${amount} ${unit.trim()}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatNumberWithUnit(
|
|
255
|
+
value: number,
|
|
256
|
+
unit: string,
|
|
257
|
+
context: PlanFeatureFormatContext,
|
|
258
|
+
): string {
|
|
259
|
+
const parsed = parseUnit(unit);
|
|
260
|
+
if (parsed.kind === "quantity" && MONEY_UNITS.has(parsed.base)) {
|
|
261
|
+
return formatMajorUnits(value, context.currency, context.locale);
|
|
262
|
+
}
|
|
263
|
+
const rule = findUnitRule(parsed.base);
|
|
264
|
+
if (!rule) return formatWithUnknownUnit(value, parsed, unit, context);
|
|
265
|
+
return parsed.kind === "price"
|
|
266
|
+
? formatPriceInRule(value, rule, context)
|
|
267
|
+
: formatQuantityInRule(value, rule, context);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isTruthyFlag(value: unknown): boolean {
|
|
271
|
+
return value === true || value === "true" || value === 1;
|
|
272
|
+
}
|
|
273
|
+
|
|
12
274
|
function formatBoolean(value: unknown): string {
|
|
13
|
-
return value ? INCLUDED_LABEL : EXCLUDED_LABEL;
|
|
275
|
+
return isTruthyFlag(value) ? INCLUDED_LABEL : EXCLUDED_LABEL;
|
|
14
276
|
}
|
|
15
277
|
|
|
16
278
|
function formatNumeric(
|
|
17
279
|
value: unknown,
|
|
18
280
|
unit: string | null,
|
|
19
|
-
|
|
281
|
+
context: PlanFeatureFormatContext,
|
|
20
282
|
): string {
|
|
21
283
|
const numeric = Number(value);
|
|
22
284
|
if (Number.isNaN(numeric)) return EMPTY_LABEL;
|
|
23
|
-
if (numeric === UNLIMITED_NUMERIC_VALUE) return unlimitedLabel;
|
|
24
|
-
|
|
285
|
+
if (numeric === UNLIMITED_NUMERIC_VALUE) return context.unlimitedLabel;
|
|
286
|
+
if (!unit?.trim()) return formatNumber(numeric, context.locale);
|
|
287
|
+
return formatNumberWithUnit(numeric, unit, context);
|
|
25
288
|
}
|
|
26
289
|
|
|
27
|
-
function formatText(
|
|
290
|
+
function formatText(
|
|
291
|
+
value: unknown,
|
|
292
|
+
unit: string | null,
|
|
293
|
+
context: PlanFeatureFormatContext,
|
|
294
|
+
): string {
|
|
28
295
|
const text = String(value).trim();
|
|
29
296
|
if (!text) return EMPTY_LABEL;
|
|
30
|
-
|
|
297
|
+
const shown = context.translateText(text);
|
|
298
|
+
return unit ? `${shown} ${unit}` : shown;
|
|
31
299
|
}
|
|
32
300
|
|
|
33
301
|
const FORMATTERS: Record<string, FeatureValueFormatter> = {
|
|
34
302
|
boolean: (value) => formatBoolean(value),
|
|
35
|
-
number:
|
|
36
|
-
|
|
37
|
-
string: (value, unit) => formatText(value, unit),
|
|
303
|
+
number: formatNumeric,
|
|
304
|
+
string: formatText,
|
|
38
305
|
};
|
|
39
306
|
|
|
40
307
|
/**
|
|
41
|
-
* Renders a plan's feature value
|
|
42
|
-
*
|
|
308
|
+
* Renders a plan's feature value for people: `-1` reads as unlimited (the
|
|
309
|
+
* convention `maxMembers` already uses), booleans as ✓/—, numbers grouped in
|
|
310
|
+
* the viewer's locale and scaled by unit — bytes to KB…TB, plus any unit a
|
|
311
|
+
* consumer registered — and `per <unit>` prices read per the same readable
|
|
312
|
+
* unit. Text values follow the DMS display string convention: a `$key` is
|
|
313
|
+
* translated, plain text is shown as stored.
|
|
314
|
+
*
|
|
315
|
+
* @param feature Feature definition carrying the value type and unit
|
|
316
|
+
* @param value Value a plan sets for the feature
|
|
317
|
+
* @param context Locale, plan currency and unit label translator
|
|
43
318
|
*/
|
|
319
|
+
export function formatPlanFeatureValue(
|
|
320
|
+
feature: FormattableFeature,
|
|
321
|
+
value: unknown,
|
|
322
|
+
context: PlanFeatureFormatContext,
|
|
323
|
+
): string {
|
|
324
|
+
if (value === undefined || value === null || value === "") {
|
|
325
|
+
return EMPTY_LABEL;
|
|
326
|
+
}
|
|
327
|
+
const formatter = FORMATTERS[feature.valueType];
|
|
328
|
+
if (!formatter) return String(value);
|
|
329
|
+
return formatter(value, feature.unit, context);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Locale-bound view of {@link formatPlanFeatureValue}, for components. */
|
|
44
333
|
export function usePlanFeatureFormat() {
|
|
45
|
-
const { t } = useI18n();
|
|
334
|
+
const { t, locale } = useI18n();
|
|
335
|
+
const { processI18n } = useTranslation();
|
|
46
336
|
|
|
47
337
|
function formatFeatureValue(
|
|
48
338
|
feature: TenantPlanFeature,
|
|
49
339
|
value: unknown,
|
|
340
|
+
currency: string | null = null,
|
|
50
341
|
): string {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
342
|
+
return formatPlanFeatureValue(feature, value, {
|
|
343
|
+
locale: locale.value,
|
|
344
|
+
currency,
|
|
345
|
+
unlimitedLabel: t("saas.workspace.plan.unlimited"),
|
|
346
|
+
translateUnit: (key, params, count) => t(key, params, count),
|
|
347
|
+
translateText: (text) => processI18n(text),
|
|
348
|
+
});
|
|
57
349
|
}
|
|
58
350
|
|
|
59
351
|
return { formatFeatureValue };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The part of a feature definition its label and tooltip come from. */
|
|
2
|
+
export interface LabelledFeature {
|
|
3
|
+
displayName: string;
|
|
4
|
+
tooltip: string | null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Feature label and tooltip readers for the plan pages. Both follow the DMS
|
|
9
|
+
* display string convention: a stored value starting with `$` is an i18n key
|
|
10
|
+
* the declaring module ships in its own locales, anything else is shown as
|
|
11
|
+
* written. A `$` tooltip missing from the locales is hidden rather than shown
|
|
12
|
+
* as a bare key path.
|
|
13
|
+
*/
|
|
14
|
+
export function usePlanFeatureLabel() {
|
|
15
|
+
const { t } = useI18n();
|
|
16
|
+
const { processI18n } = useTranslation();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
featureLabel: (feature: LabelledFeature) =>
|
|
20
|
+
processI18n(feature.displayName),
|
|
21
|
+
featureTooltip: (feature: LabelledFeature) =>
|
|
22
|
+
resolveOptionalI18nKey(t, feature.tooltip ?? undefined) ?? null,
|
|
23
|
+
};
|
|
24
|
+
}
|