@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.
Files changed (195) hide show
  1. package/README.md +307 -33
  2. package/dist/billing-state/index.d.ts +1 -0
  3. package/dist/billing-state/index.js +1 -0
  4. package/dist/billing-state/index.js.map +1 -1
  5. package/dist/billing-state/past-due-banner.d.ts +4 -0
  6. package/dist/billing-state/past-due-banner.js +25 -0
  7. package/dist/billing-state/past-due-banner.js.map +1 -0
  8. package/dist/config/runtime.d.ts +6 -1
  9. package/dist/config/runtime.js +50 -1
  10. package/dist/config/runtime.js.map +1 -1
  11. package/dist/crons/recompute-billing-state.js +4 -1
  12. package/dist/crons/recompute-billing-state.js.map +1 -1
  13. package/dist/data-api/platformOwner/index.d.ts +1 -0
  14. package/dist/data-api/platformOwner/index.js +1 -0
  15. package/dist/data-api/platformOwner/index.js.map +1 -1
  16. package/dist/data-api/platformOwner/invitations.d.ts +22 -0
  17. package/dist/data-api/platformOwner/invitations.js +121 -0
  18. package/dist/data-api/platformOwner/invitations.js.map +1 -0
  19. package/dist/data-api/platformOwner/invoice-options.js +3 -2
  20. package/dist/data-api/platformOwner/invoice-options.js.map +1 -1
  21. package/dist/data-api/platformOwner/workspaces.d.ts +3 -0
  22. package/dist/data-api/platformOwner/workspaces.js +64 -12
  23. package/dist/data-api/platformOwner/workspaces.js.map +1 -1
  24. package/dist/data-api/tenant/tenant-invoices.js +2 -10
  25. package/dist/data-api/tenant/tenant-invoices.js.map +1 -1
  26. package/dist/hooks/register.js +2 -1
  27. package/dist/hooks/register.js.map +1 -1
  28. package/dist/implementations/dms-saas/billing.d.ts +2 -1
  29. package/dist/implementations/dms-saas/billing.js +6 -0
  30. package/dist/implementations/dms-saas/billing.js.map +1 -1
  31. package/dist/index.js +5 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/invoice-line-items/billing-window.js +2 -2
  34. package/dist/invoice-line-items/billing-window.js.map +1 -1
  35. package/dist/invoice-line-items/injection.d.ts +10 -0
  36. package/dist/invoice-line-items/injection.js +7 -3
  37. package/dist/invoice-line-items/injection.js.map +1 -1
  38. package/dist/operator-actions/db/lifecycle-delivery.model.js +1 -3
  39. package/dist/operator-actions/db/lifecycle-delivery.model.js.map +1 -1
  40. package/dist/operator-actions/db/lifecycle-delivery.table.d.ts +1 -1
  41. package/dist/operator-actions/db/lifecycle-delivery.table.js.map +1 -1
  42. package/dist/operator-actions/db/operator-action.model.js +1 -3
  43. package/dist/operator-actions/db/operator-action.model.js.map +1 -1
  44. package/dist/operator-actions/db/operator-action.table.d.ts +1 -1
  45. package/dist/operator-actions/db/operator-action.table.js.map +1 -1
  46. package/dist/operator-actions/index.d.ts +1 -0
  47. package/dist/operator-actions/index.js +1 -0
  48. package/dist/operator-actions/index.js.map +1 -1
  49. package/dist/operator-actions/invitation-commands.d.ts +12 -0
  50. package/dist/operator-actions/invitation-commands.js +46 -0
  51. package/dist/operator-actions/invitation-commands.js.map +1 -0
  52. package/dist/operator-actions/types.d.ts +1 -1
  53. package/dist/pages/platform/dashboard.js +2 -4
  54. package/dist/pages/platform/dashboard.js.map +1 -1
  55. package/dist/pages/platform/workspaces/detail.js +40 -1
  56. package/dist/pages/platform/workspaces/detail.js.map +1 -1
  57. package/dist/pages/platform/workspaces/index.d.ts +2 -8
  58. package/dist/pages/platform/workspaces/index.js +27 -111
  59. package/dist/pages/platform/workspaces/index.js.map +1 -1
  60. package/dist/pages/public/legal/privacy-policy.js +2 -1
  61. package/dist/pages/public/legal/privacy-policy.js.map +1 -1
  62. package/dist/pages/public/legal/terms-and-conditions.js +2 -1
  63. package/dist/pages/public/legal/terms-and-conditions.js.map +1 -1
  64. package/dist/pages/public/legal/terms-of-use.js +2 -1
  65. package/dist/pages/public/legal/terms-of-use.js.map +1 -1
  66. package/dist/plans/index.d.ts +1 -0
  67. package/dist/plans/index.js +1 -0
  68. package/dist/plans/index.js.map +1 -1
  69. package/dist/plans/platform-support.d.ts +2 -0
  70. package/dist/plans/platform-support.js +19 -0
  71. package/dist/plans/platform-support.js.map +1 -0
  72. package/dist/plans/seat-capacity.d.ts +9 -2
  73. package/dist/plans/seat-capacity.js +59 -12
  74. package/dist/plans/seat-capacity.js.map +1 -1
  75. package/dist/plans/seat-hooks.js +34 -13
  76. package/dist/plans/seat-hooks.js.map +1 -1
  77. package/dist/routes/platformOwner/credit-notes-issue.js +2 -1
  78. package/dist/routes/platformOwner/credit-notes-issue.js.map +1 -1
  79. package/dist/routes/platformOwner/dashboard.d.ts +8 -6
  80. package/dist/routes/platformOwner/dashboard.js +31 -17
  81. package/dist/routes/platformOwner/dashboard.js.map +1 -1
  82. package/dist/routes/platformOwner/index.d.ts +1 -0
  83. package/dist/routes/platformOwner/index.js +1 -0
  84. package/dist/routes/platformOwner/index.js.map +1 -1
  85. package/dist/routes/platformOwner/workspace-invitations.d.ts +13 -0
  86. package/dist/routes/platformOwner/workspace-invitations.js +73 -0
  87. package/dist/routes/platformOwner/workspace-invitations.js.map +1 -0
  88. package/dist/routes/public/register.d.ts +7 -3
  89. package/dist/routes/public/register.js +42 -27
  90. package/dist/routes/public/register.js.map +1 -1
  91. package/dist/routes/tenant/tenant-billing.d.ts +7 -11
  92. package/dist/routes/tenant/tenant-billing.js +34 -26
  93. package/dist/routes/tenant/tenant-billing.js.map +1 -1
  94. package/dist/routes/tenant/tenant-plan-ops.js +2 -2
  95. package/dist/routes/tenant/tenant-plan-ops.js.map +1 -1
  96. package/dist/routes/tenant/tenant-plan.d.ts +1 -0
  97. package/dist/routes/tenant/tenant-plan.js +27 -2
  98. package/dist/routes/tenant/tenant-plan.js.map +1 -1
  99. package/dist/routes/tenant/tenant-seats.d.ts +2 -4
  100. package/dist/routes/tenant/tenant-seats.js.map +1 -1
  101. package/dist/routes/tenant/workspaces.d.ts +2 -0
  102. package/dist/routes/tenant/workspaces.js +10 -4
  103. package/dist/routes/tenant/workspaces.js.map +1 -1
  104. package/dist/stripe/upcoming-invoice.d.ts +21 -0
  105. package/dist/stripe/upcoming-invoice.js +42 -0
  106. package/dist/stripe/upcoming-invoice.js.map +1 -0
  107. package/dist/stripe/webhook-dispatch.js +2 -0
  108. package/dist/stripe/webhook-dispatch.js.map +1 -1
  109. package/dist/types/config.d.ts +9 -0
  110. package/dist/types/config.js +7 -0
  111. package/dist/types/config.js.map +1 -1
  112. package/dist/upcoming-invoice/db/preview-cache.model.d.ts +38 -0
  113. package/dist/upcoming-invoice/db/preview-cache.model.js +57 -0
  114. package/dist/upcoming-invoice/db/preview-cache.model.js.map +1 -0
  115. package/dist/upcoming-invoice/db/preview-cache.table.d.ts +10 -0
  116. package/dist/upcoming-invoice/db/preview-cache.table.js +42 -0
  117. package/dist/upcoming-invoice/db/preview-cache.table.js.map +1 -0
  118. package/dist/upcoming-invoice/eligibility.d.ts +17 -0
  119. package/dist/upcoming-invoice/eligibility.js +52 -0
  120. package/dist/upcoming-invoice/eligibility.js.map +1 -0
  121. package/dist/upcoming-invoice/invalidation.d.ts +2 -0
  122. package/dist/upcoming-invoice/invalidation.js +39 -0
  123. package/dist/upcoming-invoice/invalidation.js.map +1 -0
  124. package/dist/upcoming-invoice/mapping.d.ts +8 -0
  125. package/dist/upcoming-invoice/mapping.js +82 -0
  126. package/dist/upcoming-invoice/mapping.js.map +1 -0
  127. package/dist/upcoming-invoice/outcomes.d.ts +13 -0
  128. package/dist/upcoming-invoice/outcomes.js +15 -0
  129. package/dist/upcoming-invoice/outcomes.js.map +1 -0
  130. package/dist/upcoming-invoice/preview.d.ts +3 -0
  131. package/dist/upcoming-invoice/preview.js +80 -0
  132. package/dist/upcoming-invoice/preview.js.map +1 -0
  133. package/dist/upcoming-invoice/pricing.d.ts +3 -0
  134. package/dist/upcoming-invoice/pricing.js +84 -0
  135. package/dist/upcoming-invoice/pricing.js.map +1 -0
  136. package/dist/upcoming-invoice/usage.d.ts +14 -0
  137. package/dist/upcoming-invoice/usage.js +53 -0
  138. package/dist/upcoming-invoice/usage.js.map +1 -0
  139. package/dist/workspaces/billing-identity.d.ts +19 -0
  140. package/dist/workspaces/billing-identity.js +51 -0
  141. package/dist/workspaces/billing-identity.js.map +1 -0
  142. package/dist/workspaces/default-plan.d.ts +5 -0
  143. package/dist/workspaces/default-plan.js +110 -0
  144. package/dist/workspaces/default-plan.js.map +1 -0
  145. package/dist/workspaces/index.d.ts +2 -0
  146. package/dist/workspaces/index.js +2 -0
  147. package/dist/workspaces/index.js.map +1 -1
  148. package/dist/workspaces/invitations.d.ts +26 -0
  149. package/dist/workspaces/invitations.js +112 -0
  150. package/dist/workspaces/invitations.js.map +1 -0
  151. package/dist/workspaces/provisioning.d.ts +3 -3
  152. package/dist/workspaces/provisioning.js +49 -11
  153. package/dist/workspaces/provisioning.js.map +1 -1
  154. package/frontend-vue/app/components/BillingIdentityFields.vue +179 -0
  155. package/frontend-vue/app/components/PastDueBanner.vue +81 -0
  156. package/frontend-vue/app/components/PaymentMethodCard.vue +94 -38
  157. package/frontend-vue/app/components/PlanComparisonModal.vue +62 -26
  158. package/frontend-vue/app/components/PlanUpgradeModal.vue +181 -0
  159. package/frontend-vue/app/components/PlatformNotes.vue +4 -0
  160. package/frontend-vue/app/components/RefundButton.vue +10 -1
  161. package/frontend-vue/app/components/Register.vue +49 -92
  162. package/frontend-vue/app/components/SeatQuotaBanner.vue +28 -0
  163. package/frontend-vue/app/components/TenantBillingForm.vue +52 -128
  164. package/frontend-vue/app/components/TenantPlanCard.vue +68 -10
  165. package/frontend-vue/app/components/UserSummaryCard.vue +12 -10
  166. package/frontend-vue/app/components/WorkspaceAdminCreateModal.vue +202 -0
  167. package/frontend-vue/app/components/WorkspaceCreateModal.vue +46 -9
  168. package/frontend-vue/app/components/WorkspaceInvitationLink.vue +106 -0
  169. package/frontend-vue/app/components/WorkspaceSummaryCard.vue +39 -15
  170. package/frontend-vue/app/components/WorkspaceSuspended.vue +3 -5
  171. package/frontend-vue/app/components/WorkspaceSwitcherWidget.vue +8 -5
  172. package/frontend-vue/app/composables/useBillingCountries.ts +25 -5
  173. package/frontend-vue/app/composables/useBillingDocumentType.ts +9 -5
  174. package/frontend-vue/app/composables/useBillingIdentity.ts +141 -0
  175. package/frontend-vue/app/composables/useInvitationLink.ts +69 -0
  176. package/frontend-vue/app/composables/usePastDueBannerAction.ts +20 -0
  177. package/frontend-vue/app/composables/usePlanComparison.ts +15 -0
  178. package/frontend-vue/app/composables/usePlanFeatureFormat.ts +312 -20
  179. package/frontend-vue/app/composables/usePlanFeatureLabel.ts +24 -0
  180. package/frontend-vue/app/composables/useSaasRegistration.ts +110 -223
  181. package/frontend-vue/app/composables/useSeatQuota.ts +8 -0
  182. package/frontend-vue/app/composables/useStripePaymentElement.ts +3 -3
  183. package/frontend-vue/app/composables/useWorkspaceAccessCache.ts +29 -2
  184. package/frontend-vue/app/composables/useWorkspaceFinalization.ts +7 -32
  185. package/frontend-vue/app/custom-pages/auth/no-workspace.vue +49 -194
  186. package/frontend-vue/app/plugins/auth-links.ts +0 -4
  187. package/frontend-vue/app/plugins/billing-data-types.ts +4 -2
  188. package/frontend-vue/app/types/runtime-config.ts +13 -0
  189. package/frontend-vue/dms.frontend.ts +10 -4
  190. package/frontend-vue/i18n/locales/saas-en-GB.json +161 -36
  191. package/frontend-vue/i18n/locales/saas-fr-FR.json +162 -37
  192. package/frontend-vue/package.json +0 -1
  193. package/frontend-vue/pnpm-lock.yaml +1 -125
  194. package/package.json +5 -5
  195. package/frontend-vue/app/middleware/workspace-suspended.global.ts +0 -59
@@ -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
- // Card details are owner-only in the API payload; showing the block to a
13
- // member would falsely read as "no card on file".
14
- const isVisible = computed(
15
- () => !!data.value?.hasStripeCustomer && !!data.value?.isTenantOwner,
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" as const, key: "missing" };
20
- if (needsUpdate.value) return { color: "warning" as const, key: "needs_update" };
21
- return { color: "success" as const, key: "active" };
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
- ? { color: "warning" as const, key: "declined" }
27
- : { color: "neutral" as const, key: "default" },
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(load);
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="isVisible">
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
- {{ $t("saas.workspace.billing.payment_method.title") }}
50
- </h3>
51
- <UBadge :color="headerBadge.color" variant="subtle">
52
- {{ $t(`saas.workspace.billing.payment_method.${headerBadge.key}`) }}
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
- <div class="flex flex-col gap-4">
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-if="card"
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="font-medium capitalize">
64
- {{ card.brand }} •••• {{ card.last4 }}
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
- <span class="grow" />
70
- <UBadge :color="cardBadge.color" variant="subtle">
71
- {{ $t(`saas.workspace.billing.payment_method.${cardBadge.key}`) }}
72
- </UBadge>
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 class="border-default flex flex-wrap items-center gap-3 border-t pt-4">
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("saas.workspace.billing.payment_method.portal_hint") }}
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
- const visibleFeatures = computed(() =>
28
- props.features.filter(
29
- (feature) => showDetailRows.value || !feature.isDetailRow,
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
- props.features.some((feature) => feature.isDetailRow),
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="w-full min-w-3xl border-collapse text-sm">
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-elevated/50' : ''"
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-muted tabular-nums">
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
- isRecovery && plan.checkoutAvailable
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 visibleFeatures"
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
- {{ feature.displayName }}
162
- <UTooltip v-if="feature.tooltip" :text="feature.tooltip">
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="feature.tooltip"
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-elevated/50' : ''"
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
- <p class="text-muted text-xs">
208
- {{ $t("saas.workspace.plan.comparison.footer") }}
209
- </p>
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>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from "vue";
3
+
4
+ const props = defineProps<{
5
+ plan: TenantPlanView | null;
6
+ }>();
7
+
8
+ const emit = defineEmits<{ changed: [] }>();
9
+
10
+ const open = defineModel<boolean>("open", { default: false });
11
+
12
+ const KEY_PREFIX = "saas.workspace.plan.upgrade";
13
+
14
+ const toast = useToast();
15
+ const { t } = useI18n();
16
+ const { resolveApiError } = useApiErrorMessage();
17
+ const identity = useBillingIdentity();
18
+ const { changePlan } = useTenantPlan();
19
+ const { formatMajorUnits } = useMoneyFormat();
20
+ const planIntervalLabel = usePlanIntervalLabel("saas.workspace.plan.interval");
21
+
22
+ const draft = ref<BillingIdentityDraft>(emptyBillingIdentityDraft());
23
+ const hasTriedSubmit = ref(false);
24
+ // Flagged once a submit was attempted, then re-checked as the user types so
25
+ // a corrected field clears its error right away.
26
+ const invalidFields = computed<BillingIdentityField[]>(() =>
27
+ hasTriedSubmit.value ? findMissingBillingFields(draft.value) : [],
28
+ );
29
+ const isLoading = ref(false);
30
+ const loadFailed = ref(false);
31
+ const isSubmitting = ref(false);
32
+
33
+ const priceLabel = computed(() =>
34
+ props.plan ? formatMajorUnits(props.plan.price, props.plan.currency) : "",
35
+ );
36
+ const intervalLabel = computed(() =>
37
+ props.plan ? planIntervalLabel(props.plan.interval) : "",
38
+ );
39
+
40
+ async function loadIdentity(): Promise<void> {
41
+ isLoading.value = true;
42
+ loadFailed.value = false;
43
+ hasTriedSubmit.value = false;
44
+ try {
45
+ draft.value = toBillingIdentityDraft(await identity.load());
46
+ } catch {
47
+ loadFailed.value = true;
48
+ } finally {
49
+ isLoading.value = false;
50
+ }
51
+ }
52
+
53
+ watch(open, (isOpen) => {
54
+ if (isOpen) void loadIdentity();
55
+ });
56
+
57
+ function notifyError(title: string): void {
58
+ toast.add({ title, color: "error", icon: "i-ph-warning-circle" });
59
+ }
60
+
61
+ /**
62
+ * The identity is saved first: the plan change checks the plan's audience
63
+ * against the stored customer type, and Stripe Checkout bills the customer
64
+ * this identity describes. The card itself is collected by Stripe.
65
+ */
66
+ async function confirm(): Promise<void> {
67
+ const plan = props.plan;
68
+ if (!plan) return;
69
+ hasTriedSubmit.value = true;
70
+ if (invalidFields.value.length > 0) {
71
+ notifyError(t("saas.workspace.billing.identity.incomplete_toast"));
72
+ return;
73
+ }
74
+ isSubmitting.value = true;
75
+ try {
76
+ await identity.save(draft.value);
77
+ const result = await changePlan(plan._id);
78
+ if (result.checkoutUrl && typeof window !== "undefined") {
79
+ window.location.href = result.checkoutUrl;
80
+ return;
81
+ }
82
+ toast.add({
83
+ title: t("saas.workspace.plan.change_applied"),
84
+ color: "success",
85
+ icon: "i-ph-check-circle",
86
+ });
87
+ open.value = false;
88
+ emit("changed");
89
+ } catch (error) {
90
+ notifyError(resolveApiError(error, "saas.workspace.plan.change_error"));
91
+ } finally {
92
+ isSubmitting.value = false;
93
+ }
94
+ }
95
+ </script>
96
+
97
+ <template>
98
+ <UModal
99
+ v-model:open="open"
100
+ :title="$t(`${KEY_PREFIX}.title`, { plan: plan?.name ?? '' })"
101
+ :description="$t(`${KEY_PREFIX}.description`)"
102
+ :ui="{ content: 'max-w-2xl' }"
103
+ >
104
+ <template #body>
105
+ <div v-if="isLoading" class="flex flex-col gap-3">
106
+ <USkeleton class="h-20 w-full" />
107
+ <USkeleton class="h-10 w-full" />
108
+ <USkeleton class="h-10 w-full" />
109
+ </div>
110
+
111
+ <DmsSaasLoadFailure v-else-if="loadFailed" @retry="loadIdentity" />
112
+
113
+ <form
114
+ v-else-if="plan"
115
+ id="saas-plan-upgrade-form"
116
+ class="flex flex-col gap-6"
117
+ novalidate
118
+ @submit.prevent="confirm"
119
+ >
120
+ <div class="border-default rounded-lg border p-4 text-sm">
121
+ <div class="flex justify-between gap-4">
122
+ <span>
123
+ {{ $t(`${KEY_PREFIX}.subscription`, { plan: plan.name }) }}
124
+ </span>
125
+ <span class="tabular-nums">
126
+ {{ priceLabel }}/{{ intervalLabel }}
127
+ </span>
128
+ </div>
129
+ <div
130
+ class="border-default mt-3 flex justify-between gap-4 border-t pt-3 font-semibold"
131
+ >
132
+ <span>{{ $t(`${KEY_PREFIX}.due_today`) }}</span>
133
+ <span class="text-primary tabular-nums">
134
+ {{ $t(`${KEY_PREFIX}.excl_tax`, { amount: priceLabel }) }}
135
+ </span>
136
+ </div>
137
+ <p class="text-muted mt-2 text-xs">
138
+ {{ $t(`${KEY_PREFIX}.tax_note`) }}
139
+ </p>
140
+ </div>
141
+
142
+ <section class="flex flex-col gap-3">
143
+ <h4 class="flex items-center gap-2 font-semibold">
144
+ <UIcon name="i-ph-identification-card" class="size-5" />
145
+ {{ $t("saas.workspace.billing.info_title") }}
146
+ </h4>
147
+ <DmsSaasBillingIdentityFields
148
+ v-model="draft"
149
+ :invalid-fields="invalidFields"
150
+ />
151
+ </section>
152
+
153
+ <section class="flex flex-col gap-2">
154
+ <h4 class="flex items-center gap-2 font-semibold">
155
+ <UIcon name="i-ph-credit-card" class="size-5" />
156
+ {{ $t(`${KEY_PREFIX}.card_title`) }}
157
+ </h4>
158
+ <p class="text-muted text-sm">{{ $t(`${KEY_PREFIX}.card_note`) }}</p>
159
+ </section>
160
+ </form>
161
+ </template>
162
+
163
+ <template #footer>
164
+ <div class="flex w-full justify-end gap-2">
165
+ <UButton color="neutral" variant="subtle" @click="open = false">
166
+ {{ $t("common.cancel") }}
167
+ </UButton>
168
+ <UButton
169
+ type="submit"
170
+ form="saas-plan-upgrade-form"
171
+ color="primary"
172
+ icon="i-ph-lock-simple"
173
+ :loading="isSubmitting"
174
+ :disabled="isLoading || loadFailed"
175
+ >
176
+ {{ $t(`${KEY_PREFIX}.confirm`) }}
177
+ </UButton>
178
+ </div>
179
+ </template>
180
+ </UModal>
181
+ </template>
@@ -30,6 +30,10 @@ const props = defineProps<{
30
30
  routeParams?: Record<string, string>;
31
31
  }>();
32
32
 
33
+ // The card and its delete modal are sibling roots, so the renderer's extra
34
+ // attributes (page and component ids) have no single element to land on.
35
+ defineOptions({ inheritAttrs: false });
36
+
33
37
  const { $authFetch } = useAuthFetch();
34
38
  const { t } = useI18n();
35
39
  const toast = useToast();
@@ -67,7 +67,16 @@ async function confirmRefund(): Promise<void> {
67
67
  }
68
68
  }
69
69
 
70
- onMounted(loadEligibility);
70
+ /** The eligibility read is owner-only and, like the refund itself, closed
71
+ * while the access gate blocks the workspace: the card has nothing to offer
72
+ * then, so it skips the request instead of collecting a 403. */
73
+ async function loadIfOffered(): Promise<void> {
74
+ const access = await loadWorkspaceAccess();
75
+ if (access && (access.blocked || !access.isTenantOwner)) return;
76
+ await loadEligibility();
77
+ }
78
+
79
+ onMounted(loadIfOffered);
71
80
  </script>
72
81
 
73
82
  <template>