@saasicat/ui-vue 0.2.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 (193) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +47 -0
  3. package/dist/index.cjs +3776 -0
  4. package/dist/index.d.cts +2395 -0
  5. package/dist/index.d.ts +2395 -0
  6. package/dist/index.js +3659 -0
  7. package/dist/testing-e2e/admin-pages-suite.cjs +134 -0
  8. package/dist/testing-e2e/admin-pages-suite.d.cts +37 -0
  9. package/dist/testing-e2e/admin-pages-suite.d.ts +37 -0
  10. package/dist/testing-e2e/admin-pages-suite.js +109 -0
  11. package/package.json +87 -0
  12. package/src/action-registry.ts +110 -0
  13. package/src/batch-column-fetcher.ts +152 -0
  14. package/src/boot-loader.ts +60 -0
  15. package/src/components/BundleVersionPublishDialog.vue +359 -0
  16. package/src/components/BusinessTypeVersionEditorDialog.vue +417 -0
  17. package/src/components/BusinessTypeVersionPublishDialog.vue +258 -0
  18. package/src/components/FeatureGate.vue +56 -0
  19. package/src/components/KpiCard.vue +53 -0
  20. package/src/components/KvBlock.vue +36 -0
  21. package/src/components/MarketingPromotionsTab.vue +861 -0
  22. package/src/components/MfaPromptDialog.vue +104 -0
  23. package/src/components/TenantActionConfirmDialog.vue +252 -0
  24. package/src/components/VersionDiffPreview.vue +150 -0
  25. package/src/components/bundle-editor/BundleCreatePanel.vue +681 -0
  26. package/src/components/bundle-editor/BundleFeaturesEditor.vue +215 -0
  27. package/src/components/bundle-editor/BundlePlanCompatPicker.vue +316 -0
  28. package/src/components/bundle-editor/BundleQuotasEditor.vue +219 -0
  29. package/src/components/bundle-editor/BundleStatusBanner.vue +189 -0
  30. package/src/components/bundle-editor/BundleVersionInlineEditor.vue +659 -0
  31. package/src/components/bundle-editor/BundleVersionStrip.vue +207 -0
  32. package/src/components/bundle-editor/bundle-version-status.ts +164 -0
  33. package/src/components/bundle-editor/catalog-i18n.ts +85 -0
  34. package/src/components/dialogs/PilotCreateDialog.vue +780 -0
  35. package/src/components/dialogs/PilotEditDialog.vue +548 -0
  36. package/src/components/dialogs/PromoCodeCreateDialog.vue +737 -0
  37. package/src/components/dialogs/PromoCodeEditDialog.vue +859 -0
  38. package/src/components/dialogs/types.ts +126 -0
  39. package/src/components/plan/PlanCycleToggle.vue +99 -0
  40. package/src/components/plan/PlanGrid.vue +211 -0
  41. package/src/components/plan/PriceSummary.vue +293 -0
  42. package/src/components/plan/PromoCodeInput.vue +192 -0
  43. package/src/components/plan/PublicBundleGrid.vue +287 -0
  44. package/src/components/plan-cockpit/PlanCockpit.vue +993 -0
  45. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +65 -0
  46. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +91 -0
  47. package/src/components/plan-cockpit/PlanCockpitHeader.vue +98 -0
  48. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +99 -0
  49. package/src/components/plan-cockpit/PlanCockpitKpis.vue +57 -0
  50. package/src/components/plan-cockpit/PlanCockpitVersions.vue +226 -0
  51. package/src/components/plan-cockpit/types.ts +72 -0
  52. package/src/components/plan-create-dialog/PlanCreateDialog.vue +508 -0
  53. package/src/components/plan-detail/PlanAuditLog.vue +59 -0
  54. package/src/components/plan-detail/PlanDetail.vue +1195 -0
  55. package/src/components/plan-detail/PlanDetailHeader.vue +185 -0
  56. package/src/components/plan-detail/PlanDetailKpis.vue +57 -0
  57. package/src/components/plan-detail/PlanTerminateDialog.vue +76 -0
  58. package/src/components/plan-detail/PlanVersionDiffPanel.vue +149 -0
  59. package/src/components/plan-detail/PlanVersionsPanel.vue +255 -0
  60. package/src/components/plan-detail/types.ts +59 -0
  61. package/src/components/plan-list/PlanList.vue +1067 -0
  62. package/src/components/plan-matrix/PlanMatrix.vue +1113 -0
  63. package/src/components/plan-review/PlanReview.vue +742 -0
  64. package/src/components/plan-version-editor/PlanCatalogPreview.vue +184 -0
  65. package/src/components/plan-version-editor/PlanComponentPool.vue +272 -0
  66. package/src/components/plan-version-editor/PlanVersionBasket.vue +281 -0
  67. package/src/components/plan-version-editor/PlanVersionDiffDialog.vue +84 -0
  68. package/src/components/plan-version-editor/PlanVersionEditor.vue +1728 -0
  69. package/src/components/plan-version-editor/PlanVersionEditorHeader.vue +94 -0
  70. package/src/components/plan-version-editor/types.ts +96 -0
  71. package/src/components/wizard-stepper/WizardStepper.vue +107 -0
  72. package/src/create-admin-routes.ts +68 -0
  73. package/src/create-super-admin-app.ts +345 -0
  74. package/src/entitlement-provider.ts +36 -0
  75. package/src/feature-router-guard.ts +77 -0
  76. package/src/http-json.ts +62 -0
  77. package/src/index.ts +98 -0
  78. package/src/manifest-loader.ts +139 -0
  79. package/src/manifest-store-factory.ts +114 -0
  80. package/src/nav-builder.ts +274 -0
  81. package/src/pages-standard/AdminLayout.vue +411 -0
  82. package/src/pages-standard/AdminManifestErrorPage.vue +79 -0
  83. package/src/pages-standard/AuditPage.vue +223 -0
  84. package/src/pages-standard/BundlesPage.vue +910 -0
  85. package/src/pages-standard/BusinessTypesPage.vue +662 -0
  86. package/src/pages-standard/DashboardPage.vue +595 -0
  87. package/src/pages-standard/DiscoveryPage.vue +663 -0
  88. package/src/pages-standard/EmailHistoryPage.vue +576 -0
  89. package/src/pages-standard/MarketingCatalogPage.vue +1771 -0
  90. package/src/pages-standard/PilotsPage.vue +577 -0
  91. package/src/pages-standard/PlanVersionsPage.vue +216 -0
  92. package/src/pages-standard/PlansPage.vue +1222 -0
  93. package/src/pages-standard/PlatformEmailPage.vue +434 -0
  94. package/src/pages-standard/PromoCodeDetailPage.vue +331 -0
  95. package/src/pages-standard/PromoCodesPage.vue +589 -0
  96. package/src/pages-standard/SubscriptionsPage.vue +159 -0
  97. package/src/pages-standard/SuperAdminLoginPage.vue +299 -0
  98. package/src/pages-standard/SuperAdminSetupWizard.vue +405 -0
  99. package/src/pages-standard/TenantDetailPage.vue +432 -0
  100. package/src/pages-standard/TenantsPage.vue +732 -0
  101. package/src/pages-standard/UsersPage.vue +504 -0
  102. package/src/pages-standard/bundles-page/BundleAccordionList.vue +116 -0
  103. package/src/pages-standard/bundles-page/BundleDetailPanel.vue +193 -0
  104. package/src/pages-standard/bundles-page/BundlesFilterBar.vue +48 -0
  105. package/src/pages-standard/bundles-page/BundlesHeader.vue +67 -0
  106. package/src/pages-standard/bundles-page/BundlesKpis.vue +39 -0
  107. package/src/pages-standard/bundles-page/types.ts +15 -0
  108. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +246 -0
  109. package/src/pages-standard/discovery-page/DiscoveryCapList.vue +171 -0
  110. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +410 -0
  111. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +31 -0
  112. package/src/pages-standard/discovery-page/DiscoveryKpis.vue +47 -0
  113. package/src/pages-standard/discovery-page/DiscoveryMetaBanner.vue +26 -0
  114. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +247 -0
  115. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +73 -0
  116. package/src/pages-standard/discovery-page/discovery-ui.ts +166 -0
  117. package/src/pages-standard/email-history.types.ts +48 -0
  118. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +448 -0
  119. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +93 -0
  120. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +138 -0
  121. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +58 -0
  122. package/src/pages-standard/marketing-catalog/types.ts +43 -0
  123. package/src/pages-standard/plan-versions/PlanDiffCard.vue +132 -0
  124. package/src/pages-standard/plan-versions/PlanVersionHeader.vue +293 -0
  125. package/src/pages-standard/plan-versions/PlanVersionStatusBadge.vue +48 -0
  126. package/src/pages-standard/plan-versions/PlanVersionsAudit.vue +307 -0
  127. package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +269 -0
  128. package/src/pages-standard/plan-versions/PlanVersionsKpi.vue +65 -0
  129. package/src/pages-standard/plan-versions/PlanVersionsList.vue +292 -0
  130. package/src/pages-standard/plan-versions/PlanVersionsMatrix.vue +261 -0
  131. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +300 -0
  132. package/src/pages-standard/plan-versions/VersionDiffPreview.vue +167 -0
  133. package/src/pages-standard/plan-versions/format.ts +53 -0
  134. package/src/pages-standard/plan-versions/types.ts +33 -0
  135. package/src/pages-standard/plans-page/PlanArchiveDialog.vue +50 -0
  136. package/src/pages-standard/plans-page/PlanBundleOverview.vue +147 -0
  137. package/src/pages-standard/plans-page/PlanDiscardDraftDialog.vue +46 -0
  138. package/src/pages-standard/plans-page/PlanPublishDialog.vue +106 -0
  139. package/src/pages-standard/plans-page/PlansPageToast.vue +69 -0
  140. package/src/pages-standard/plans-page/types.ts +18 -0
  141. package/src/pages-standard/platform-email.types.ts +41 -0
  142. package/src/pages-standard/sa-theme.css +223 -0
  143. package/src/pages-standard/tenants/StatusPill.vue +62 -0
  144. package/src/pages-standard/tenants/format.ts +38 -0
  145. package/src/pages-tenant/LimitsRow.vue +67 -0
  146. package/src/pages-tenant/MySubscriptionBundlesPage.vue +604 -0
  147. package/src/pages-tenant/OnboardingConfigurator.vue +417 -0
  148. package/src/pages-tenant/PackageSnapshotPanel.vue +241 -0
  149. package/src/pages-tenant/PendingVersionBanner.vue +126 -0
  150. package/src/pages-tenant/PlanChangeWizard.vue +630 -0
  151. package/src/pages-tenant/TenantPlanSection.vue +729 -0
  152. package/src/pages-tenant/UsageBar.vue +107 -0
  153. package/src/pages-tenant/default-i18n.ts +280 -0
  154. package/src/pages-tenant/tenant-plan-section/BundlePreviewDialog.vue +304 -0
  155. package/src/pages-tenant/tenant-plan-section/TenantBundleStore.vue +323 -0
  156. package/src/pages-tenant/tenant-plan-section/TenantFeatureMatrix.vue +122 -0
  157. package/src/pages-tenant/tenant-plan-section/TenantPlanCardHeader.vue +67 -0
  158. package/src/pages-tenant/tenant-plan-section/TenantUsageGrid.vue +31 -0
  159. package/src/plan-versions-catalog.ts +368 -0
  160. package/src/platform-loaders.ts +68 -0
  161. package/src/project-page-host.ts +108 -0
  162. package/src/testing-e2e/admin-pages-suite.ts +187 -0
  163. package/src/types.ts +58 -0
  164. package/src/use-actions.ts +25 -0
  165. package/src/use-api-list.ts +151 -0
  166. package/src/use-audit-entries.ts +48 -0
  167. package/src/use-batch-columns.ts +59 -0
  168. package/src/use-bulk-publish.ts +150 -0
  169. package/src/use-bundle-versions-map.ts +112 -0
  170. package/src/use-bundles.ts +297 -0
  171. package/src/use-business-types.ts +269 -0
  172. package/src/use-catalog-entries.ts +243 -0
  173. package/src/use-discovery.ts +158 -0
  174. package/src/use-entitlement.ts +87 -0
  175. package/src/use-live-plan-versions.ts +126 -0
  176. package/src/use-manifest.ts +57 -0
  177. package/src/use-marketing-projections.ts +160 -0
  178. package/src/use-nav.ts +33 -0
  179. package/src/use-plan-editor.ts +162 -0
  180. package/src/use-plan-versions.ts +83 -0
  181. package/src/use-plans.ts +375 -0
  182. package/src/use-platform-tenant-actions.ts +286 -0
  183. package/src/use-promotions.ts +126 -0
  184. package/src/use-public-boot.ts +40 -0
  185. package/src/use-subscription-draft.ts +355 -0
  186. package/src/use-super-admin-context.ts +93 -0
  187. package/src/use-tenant-action-flow.ts +238 -0
  188. package/src/use-tenant-billing-catalog.ts +160 -0
  189. package/src/use-tenant-billing.ts +453 -0
  190. package/src/use-tenant-manifest.ts +94 -0
  191. package/src/use-tenant-subscription-bundles.ts +148 -0
  192. package/src/use-tenants.ts +61 -0
  193. package/src/version.ts +10 -0
@@ -0,0 +1,453 @@
1
+ // useTenantBilling — Vue composable for the tenant self-service endpoints
2
+ // (`/billing/usage`, `/billing/entitlement`, `/billing/plan/preview`,
3
+ // `/billing/plan`, `/billing/subscription-bundles`, `/billing/cancel`).
4
+ //
5
+ // The consumer supplies the HTTP adapter (axios wrapper with auth header) and
6
+ // optionally an `apiPrefix`. **Convention**: `apiPrefix` is the sub-path UNDER
7
+ // the app API base URL that the HTTP adapter itself holds. Examples:
8
+ // - HTTP adapter baseURL `/api` + apiPrefix `/billing` → `/api/billing/...`
9
+ // - HTTP adapter baseURL `/api/v1` + apiPrefix `/billing` → `/api/v1/billing/...`
10
+ // Do **NOT** set `apiPrefix='/api/billing'` when the HTTP adapter already
11
+ // has `/api` as its baseURL — the result would be `/api/api/billing/...` (404).
12
+
13
+ import { ref, type Ref } from 'vue';
14
+ import { defaultHttpClient, type HttpClient } from './types.js';
15
+ import { trimTrailingSlashes } from './http-json.js';
16
+
17
+ export type BillingCycleStr = 'MONTHLY' | 'YEARLY';
18
+
19
+ export interface UsageSnapshotShape {
20
+ plan: string;
21
+ effectivePlan: string;
22
+ billingCycle: BillingCycleStr;
23
+ status: string;
24
+ isPilot: boolean;
25
+ pilotEndsAt: string | null;
26
+ trialEndsAt: string | null;
27
+ startedAt: string | null;
28
+ currentPeriodStart: string | null;
29
+ currentPeriodEnd: string | null;
30
+ pendingPlan: string | null;
31
+ pendingBillingCycle: BillingCycleStr | null;
32
+ pendingEffectiveAt: string | null;
33
+ planVersion: {
34
+ id: string;
35
+ planId: string;
36
+ version: number;
37
+ publishedAt: string | null;
38
+ supersededAt: string | null;
39
+ changeNote: string | null;
40
+ };
41
+ pendingPlanVersion: {
42
+ id: string;
43
+ planId: string;
44
+ version: number;
45
+ nonRegressive: boolean;
46
+ changeNote: string | null;
47
+ publishedChanges: unknown;
48
+ } | null;
49
+ pendingPlanVersionEffectiveAt: string | null;
50
+ pendingPlanVersionAccepted: boolean;
51
+ pendingPlanVersionAcceptedAt: string | null;
52
+ limits: {
53
+ plan: string;
54
+ quotas: Record<string, number>;
55
+ features: string[];
56
+ };
57
+ usage: Record<string, number>;
58
+ /**
59
+ * P11.4 (METAMODELL §17a): Read-only package snapshot from the
60
+ * original CheckoutOffer. `null` for subscriptions without a
61
+ * CheckoutOffer origin. The JSON structure matches the Offer
62
+ * schema; the UI can read `bundleVersionIds`, `currency`,
63
+ * `priceTotal` and more from it.
64
+ */
65
+ packageSnapshot: PackageSnapshotShape | null;
66
+ /** P11.4: Optional reference to the original CheckoutOffer. */
67
+ checkoutOfferId: string | null;
68
+ }
69
+
70
+ /**
71
+ * Self-contained package snapshot (shape of `CheckoutOffer.snapshot`).
72
+ * All fields are optional because the snapshot schema may grow and older
73
+ * subscriptions can carry leaner snapshots. The UI must stay defensive
74
+ * against missing fields.
75
+ */
76
+ export interface PackageSnapshotShape {
77
+ planId?: string;
78
+ planVersionId?: string;
79
+ billingCycle?: BillingCycleStr;
80
+ bundleVersionIds?: string[];
81
+ currency?: string;
82
+ priceMonthlyNet?: number | null;
83
+ priceYearlyNet?: number | null;
84
+ priceTotalNet?: number | null;
85
+ label?: string;
86
+ capturedAt?: string;
87
+ [key: string]: unknown;
88
+ }
89
+
90
+ export interface PlanChangePreviewShape {
91
+ changeType: 'UPGRADE' | 'DOWNGRADE' | 'CYCLE_CHANGE' | 'NOOP';
92
+ current: { plan: PlanSnapshotShape; billingCycle: BillingCycleStr };
93
+ target: { plan: PlanSnapshotShape; billingCycle: BillingCycleStr };
94
+ effectiveAt: string | null;
95
+ isImmediate: boolean;
96
+ /** Projected new trial end (ISO) after the change, otherwise null. */
97
+ projectedTrialEndsAt: string | null;
98
+ proration: {
99
+ daysRemainingInPeriod: number;
100
+ daysInPeriod: number;
101
+ periodStart: string;
102
+ periodEnd: string;
103
+ currentPriceNet: number;
104
+ targetPriceNet: number;
105
+ prorataDeltaNet: number;
106
+ } | null;
107
+ limitsCheck: Record<
108
+ string,
109
+ {
110
+ used: number;
111
+ currentMax: number;
112
+ targetMax: number;
113
+ exceeded: boolean;
114
+ }
115
+ >;
116
+ featuresLost: string[];
117
+ featuresGained: string[];
118
+ blockers: Array<{ code: string; message: string }>;
119
+ warnings: Array<{ code: string; message: string }>;
120
+ }
121
+
122
+ export interface PlanSnapshotShape {
123
+ id: string;
124
+ name: string;
125
+ monthlyNet: number | null;
126
+ yearlyNet: number | null;
127
+ quotas: Record<string, number>;
128
+ features: string[];
129
+ }
130
+
131
+ /**
132
+ * Booked catalog bundle (wire shape of `SubscriptionBundleRecord`, dates
133
+ * as ISO strings). Source: `GET /billing/subscription-bundles`. The label/
134
+ * price is joined by the consumer via `bundleVersionId` against the bundle
135
+ * catalog (`GET /billing/bundles`) — the record itself carries only the
136
+ * version reference.
137
+ */
138
+ export interface SubscriptionBundleShape {
139
+ id: string;
140
+ subscriptionId: string;
141
+ bundleVersionId: string;
142
+ /** Denormalized (server-side from the booked bundleVersion): label/
143
+ * key/price, so that booked bundles can be shown without a catalog join. */
144
+ bundleKey?: string | null;
145
+ label?: string | null;
146
+ monthlyNet?: string | null;
147
+ startedAt: string;
148
+ minimumTermEndsAt: string | null;
149
+ canceledAt: string | null;
150
+ canceledEffectiveAt: string | null;
151
+ }
152
+
153
+ export interface BundlePreviewIssueShape {
154
+ code: string;
155
+ message: string;
156
+ }
157
+
158
+ export interface BundlePreviewSnapshotShape {
159
+ bundleKey: string;
160
+ label: string;
161
+ bundleVersionId: string;
162
+ features: string[];
163
+ quotas: Record<string, number>;
164
+ }
165
+
166
+ /** AK-13: Feature is already paid for elsewhere — double-payment hint. */
167
+ export interface RedundantFeatureHintShape {
168
+ featureKey: string;
169
+ coveredBy: 'PLAN' | 'BUNDLE';
170
+ coveredByKey: string;
171
+ }
172
+
173
+ /**
174
+ * Wire shape of `SubscriptionBundleAddPreviewDto` (#37,
175
+ * `POST /billing/subscription-bundles/preview` with `bundleVersionId`).
176
+ * `proration` is `null` during TRIAL or without a maintained list price.
177
+ */
178
+ export interface BundleAddPreviewShape {
179
+ action: 'add';
180
+ bundle: BundlePreviewSnapshotShape;
181
+ billingCycle: string;
182
+ proration: {
183
+ daysRemainingInPeriod: number;
184
+ daysInPeriod: number;
185
+ periodStart: string;
186
+ periodEnd: string;
187
+ currentPriceNet: number;
188
+ targetPriceNet: number;
189
+ prorataDeltaNet: number;
190
+ } | null;
191
+ nextPeriodPriceNet: number | null;
192
+ minimumTermMonths: number;
193
+ minimumTermEndsAt: string | null;
194
+ redundantFeatures: RedundantFeatureHintShape[];
195
+ missingRequires: string[];
196
+ blockers: BundlePreviewIssueShape[];
197
+ warnings: BundlePreviewIssueShape[];
198
+ }
199
+
200
+ /**
201
+ * Wire shape of `SubscriptionBundleCancelPreviewDto` (#37, preview with
202
+ * `subscriptionBundleId`). `effectiveAt` = max(period end, minimum term).
203
+ */
204
+ export interface BundleCancelPreviewShape {
205
+ action: 'cancel';
206
+ subscriptionBundleId: string;
207
+ bundle: BundlePreviewSnapshotShape;
208
+ billingCycle: string;
209
+ effectiveAt: string;
210
+ nextPeriodSavingsNet: number | null;
211
+ blockers: BundlePreviewIssueShape[];
212
+ warnings: BundlePreviewIssueShape[];
213
+ }
214
+
215
+ export type BundlePreviewShape = BundleAddPreviewShape | BundleCancelPreviewShape;
216
+
217
+ export interface UseTenantBillingOptions {
218
+ /**
219
+ * Default `'/billing'`. The app HTTP adapter sets the API base URL
220
+ * (e.g. `/api` or `/api/v1`); `apiPrefix` is
221
+ * the sub-path below it. A doubled `/api` prefix leads to HTTP 404.
222
+ */
223
+ apiPrefix?: string;
224
+ http?: HttpClient;
225
+ getAuthToken?: () => string | null;
226
+ /** Default `true`. */
227
+ autoLoad?: boolean;
228
+ }
229
+
230
+ export interface UseTenantBillingResult {
231
+ usage: Ref<UsageSnapshotShape | null>;
232
+ loading: Ref<boolean>;
233
+ error: Ref<Error | null>;
234
+ reload: () => Promise<void>;
235
+ previewPlanChange: (
236
+ plan: string,
237
+ billingCycle: BillingCycleStr,
238
+ ) => Promise<PlanChangePreviewShape>;
239
+ changePlan: (
240
+ plan: string,
241
+ billingCycle: BillingCycleStr,
242
+ effectiveImmediately: boolean,
243
+ ) => Promise<void>;
244
+ acceptPendingPlanVersion: () => Promise<void>;
245
+ cancelSubscription: (immediately: boolean) => Promise<void>;
246
+ /** True if `usage.value.features` contains the FeatureKey. */
247
+ hasFeature: (key: string) => boolean;
248
+
249
+ /**
250
+ * The tenant's booked catalog bundles (`/billing/subscription-bundles`).
251
+ * Loaded along with `reload()`. If the endpoint is missing (consumer
252
+ * without `SubscriptionBundleModule`), the list stays empty without
253
+ * setting the main `error` — the page degrades gracefully.
254
+ */
255
+ subscriptionBundles: Ref<SubscriptionBundleShape[]>;
256
+ /** Reloads only the booked bundles (non-fatal). */
257
+ loadBundles: () => Promise<void>;
258
+ /** Books a bundle via `bundleVersionId` + reloads the list. */
259
+ addBundle: (bundleVersionId: string, minimumTermMonths?: number) => Promise<void>;
260
+ /** Cancels a booked bundle via SubscriptionBundle PK + reloads. */
261
+ cancelBundle: (subscriptionBundleId: string) => Promise<void>;
262
+ /** Reverses a cancellation that has not yet taken effect + reloads. */
263
+ reactivateBundle: (subscriptionBundleId: string) => Promise<void>;
264
+ /**
265
+ * Add preview (#37): proration, next-period price, redundancy hint,
266
+ * requires check and blockers — show BEFORE booking.
267
+ */
268
+ previewAddBundle: (
269
+ bundleVersionId: string,
270
+ minimumTermMonths?: number,
271
+ ) => Promise<BundleAddPreviewShape>;
272
+ /** Cancel preview (#37): effective date + savings from the next period on. */
273
+ previewCancelBundle: (subscriptionBundleId: string) => Promise<BundleCancelPreviewShape>;
274
+ }
275
+
276
+ export function useTenantBilling(options: UseTenantBillingOptions = {}): UseTenantBillingResult {
277
+ const apiPrefix = trimTrailingSlashes(options.apiPrefix ?? '/billing');
278
+ const http = options.http ?? defaultHttpClient();
279
+
280
+ const usage = ref<UsageSnapshotShape | null>(null);
281
+ const loading = ref(false);
282
+ const error = ref<Error | null>(null);
283
+ const subscriptionBundles = ref<SubscriptionBundleShape[]>([]);
284
+
285
+ function authHeaders(): Record<string, string> {
286
+ const headers: Record<string, string> = {};
287
+ const token = options.getAuthToken?.();
288
+ if (token) headers.Authorization = `Bearer ${token}`;
289
+ return headers;
290
+ }
291
+
292
+ async function fetchOrThrow<T>(
293
+ path: string,
294
+ init?: { method?: string; body?: unknown },
295
+ ): Promise<T> {
296
+ const res = await http(`${apiPrefix}${path}`, {
297
+ method: init?.method ?? 'GET',
298
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
299
+ body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
300
+ });
301
+ if (res.status >= 400) {
302
+ // Wrapper error with status — the caller (UI) decides whether specific
303
+ // codes (402 Payment Required, 403 Feature Locked) get special handling.
304
+ let body: unknown;
305
+ try {
306
+ body = await res.json();
307
+ } catch {
308
+ body = await res.text();
309
+ }
310
+ const err = new Error(
311
+ `${apiPrefix}${path} → HTTP ${res.status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`,
312
+ ) as Error & { status?: number; body?: unknown };
313
+ err.status = res.status;
314
+ err.body = body;
315
+ throw err;
316
+ }
317
+ if (res.status === 204) return undefined as T;
318
+ return (await res.json()) as T;
319
+ }
320
+
321
+ async function reload() {
322
+ loading.value = true;
323
+ error.value = null;
324
+ try {
325
+ usage.value = await fetchOrThrow<UsageSnapshotShape>('/usage');
326
+ } catch (err) {
327
+ error.value = err instanceof Error ? err : new Error(String(err));
328
+ usage.value = null;
329
+ } finally {
330
+ loading.value = false;
331
+ }
332
+ // Reload bundles non-fatally — a missing/4xx endpoint must not tip the
333
+ // plan page into an error state.
334
+ await loadBundles();
335
+ }
336
+
337
+ async function loadBundles() {
338
+ try {
339
+ subscriptionBundles.value =
340
+ (await fetchOrThrow<SubscriptionBundleShape[]>('/subscription-bundles')) ?? [];
341
+ } catch {
342
+ subscriptionBundles.value = [];
343
+ }
344
+ }
345
+
346
+ async function addBundle(bundleVersionId: string, minimumTermMonths?: number) {
347
+ await fetchOrThrow('/subscription-bundles', {
348
+ method: 'POST',
349
+ body:
350
+ minimumTermMonths !== undefined
351
+ ? { bundleVersionId, minimumTermMonths }
352
+ : { bundleVersionId },
353
+ });
354
+ await loadBundles();
355
+ }
356
+
357
+ async function cancelBundle(subscriptionBundleId: string) {
358
+ await fetchOrThrow(`/subscription-bundles/${subscriptionBundleId}`, {
359
+ method: 'DELETE',
360
+ body: {},
361
+ });
362
+ await loadBundles();
363
+ }
364
+
365
+ async function reactivateBundle(subscriptionBundleId: string) {
366
+ await fetchOrThrow(`/subscription-bundles/${subscriptionBundleId}/reactivate`, {
367
+ method: 'POST',
368
+ body: {},
369
+ });
370
+ await loadBundles();
371
+ }
372
+
373
+ async function previewAddBundle(
374
+ bundleVersionId: string,
375
+ minimumTermMonths?: number,
376
+ ): Promise<BundleAddPreviewShape> {
377
+ return fetchOrThrow<BundleAddPreviewShape>('/subscription-bundles/preview', {
378
+ method: 'POST',
379
+ body:
380
+ minimumTermMonths !== undefined
381
+ ? { bundleVersionId, minimumTermMonths }
382
+ : { bundleVersionId },
383
+ });
384
+ }
385
+
386
+ async function previewCancelBundle(
387
+ subscriptionBundleId: string,
388
+ ): Promise<BundleCancelPreviewShape> {
389
+ return fetchOrThrow<BundleCancelPreviewShape>('/subscription-bundles/preview', {
390
+ method: 'POST',
391
+ body: { subscriptionBundleId },
392
+ });
393
+ }
394
+
395
+ async function previewPlanChange(plan: string, billingCycle: BillingCycleStr) {
396
+ return fetchOrThrow<PlanChangePreviewShape>('/plan/preview', {
397
+ method: 'POST',
398
+ body: { plan, billingCycle },
399
+ });
400
+ }
401
+
402
+ async function changePlan(
403
+ plan: string,
404
+ billingCycle: BillingCycleStr,
405
+ effectiveImmediately: boolean,
406
+ ) {
407
+ await fetchOrThrow('/plan', {
408
+ method: 'POST',
409
+ body: { plan, billingCycle, effectiveImmediately },
410
+ });
411
+ await reload();
412
+ }
413
+
414
+ async function acceptPendingPlanVersion() {
415
+ await fetchOrThrow('/subscription/accept-pending-version', { method: 'POST' });
416
+ await reload();
417
+ }
418
+
419
+ async function cancelSubscription(immediately: boolean) {
420
+ await fetchOrThrow('/cancel', {
421
+ method: 'POST',
422
+ body: { immediately },
423
+ });
424
+ await reload();
425
+ }
426
+
427
+ function hasFeature(key: string): boolean {
428
+ return usage.value?.limits.features.includes(key) ?? false;
429
+ }
430
+
431
+ if (options.autoLoad !== false) {
432
+ Promise.resolve().then(() => void reload());
433
+ }
434
+
435
+ return {
436
+ usage,
437
+ loading,
438
+ error,
439
+ reload,
440
+ previewPlanChange,
441
+ changePlan,
442
+ acceptPendingPlanVersion,
443
+ cancelSubscription,
444
+ hasFeature,
445
+ subscriptionBundles,
446
+ loadBundles,
447
+ addBundle,
448
+ cancelBundle,
449
+ reactivateBundle,
450
+ previewAddBundle,
451
+ previewCancelBundle,
452
+ };
453
+ }
@@ -0,0 +1,94 @@
1
+ // useTenantManifest — Vue composable for the Tenant manifest endpoint
2
+ // (see @saasicat/nest §P14: `GET /tenant/manifest`).
3
+ //
4
+ // Returns the plan ID, features, quotas and the filtered navigation that the
5
+ // backend renders per Tenant. This lets the app UI build the navigation
6
+ // declaratively from the Manifest instead of filtering it itself.
7
+ //
8
+ // Spec: handoff/superadmin/QUICKSTART_SIMPLIFICATIONS.md §P14.
9
+
10
+ import { ref, type Ref } from 'vue';
11
+ import { defaultHttpClient, type HttpClient } from './types.js';
12
+
13
+ export interface TenantManifestNavItem {
14
+ id: string;
15
+ label: string;
16
+ path: string;
17
+ icon?: string;
18
+ order?: number;
19
+ }
20
+
21
+ export interface TenantManifestShape {
22
+ schemaVersion: 1;
23
+ tenant: { id: string };
24
+ planId: string | null;
25
+ features: string[];
26
+ quotas: Record<string, number>;
27
+ navigation: TenantManifestNavItem[];
28
+ }
29
+
30
+ export interface UseTenantManifestOptions {
31
+ /** e.g. `/api/tenant/manifest` (mandatory). */
32
+ endpoint: string;
33
+ http?: HttpClient;
34
+ getAuthToken?: () => string | null;
35
+ /** Defaults to `true`. */
36
+ autoLoad?: boolean;
37
+ }
38
+
39
+ export interface UseTenantManifestResult {
40
+ manifest: Ref<TenantManifestShape | null>;
41
+ loading: Ref<boolean>;
42
+ error: Ref<Error | null>;
43
+ load: () => Promise<void>;
44
+ hasFeature: (key: string) => boolean;
45
+ /** Quota limit (`null` if the Quota is not in the Plan). */
46
+ quotaLimit: (key: string) => number | null;
47
+ }
48
+
49
+ export function useTenantManifest(options: UseTenantManifestOptions): UseTenantManifestResult {
50
+ if (!options?.endpoint) {
51
+ throw new Error(
52
+ 'useTenantManifest: `endpoint` ist Pflicht (z. B. "/api/tenant/manifest").',
53
+ );
54
+ }
55
+ const http = options.http ?? defaultHttpClient();
56
+ const manifest = ref<TenantManifestShape | null>(null);
57
+ const loading = ref(false);
58
+ const error = ref<Error | null>(null);
59
+
60
+ async function load(): Promise<void> {
61
+ loading.value = true;
62
+ error.value = null;
63
+ try {
64
+ const headers: Record<string, string> = {};
65
+ const token = options.getAuthToken?.();
66
+ if (token) headers.Authorization = `Bearer ${token}`;
67
+ const res = await http(options.endpoint, { method: 'GET', headers });
68
+ if (res.status !== 200) {
69
+ throw new Error(`Tenant-Manifest-Endpoint → HTTP ${res.status}`);
70
+ }
71
+ manifest.value = (await res.json()) as TenantManifestShape;
72
+ } catch (err) {
73
+ error.value = err instanceof Error ? err : new Error(String(err));
74
+ manifest.value = null;
75
+ } finally {
76
+ loading.value = false;
77
+ }
78
+ }
79
+
80
+ function hasFeature(key: string): boolean {
81
+ return manifest.value?.features.includes(key) ?? false;
82
+ }
83
+
84
+ function quotaLimit(key: string): number | null {
85
+ const v = manifest.value?.quotas[key];
86
+ return v === undefined ? null : v;
87
+ }
88
+
89
+ if (options.autoLoad !== false) {
90
+ Promise.resolve().then(() => void load());
91
+ }
92
+
93
+ return { manifest, loading, error, load, hasFeature, quotaLimit };
94
+ }
@@ -0,0 +1,148 @@
1
+ // useTenantSubscriptionBundles — Vue 3 composable for the tenant self-service
2
+ // page "Meine Bundles" (P11.7.3). Talks to the platform endpoint
3
+ // `/billing/subscription-bundles` (mounted by
4
+ // `SubscriptionBundleModule.forRoot({ controller: {...} })`).
5
+
6
+ import { ref, type Ref } from 'vue';
7
+ import type { SubscriptionBundleRecord } from '@saasicat/types';
8
+ import { defaultHttpClient, type HttpClient } from './types.js';
9
+
10
+ export interface UseTenantSubscriptionBundlesOptions {
11
+ /** App-global API prefix incl. `/billing` (e.g. `/api/v1`). */
12
+ billingEndpoint: string;
13
+ http?: HttpClient;
14
+ getAuthToken?: () => string | null;
15
+ /** With `true`, loads on mount. Default `false`. */
16
+ autoLoad?: boolean;
17
+ }
18
+
19
+ export interface UseTenantSubscriptionBundlesResult {
20
+ bundles: Ref<SubscriptionBundleRecord[]>;
21
+ loading: Ref<boolean>;
22
+ error: Ref<Error | null>;
23
+
24
+ load: () => Promise<void>;
25
+ add: (data: {
26
+ bundleVersionId: string;
27
+ minimumTermMonths?: number;
28
+ }) => Promise<SubscriptionBundleRecord>;
29
+ cancel: (
30
+ subscriptionBundleId: string,
31
+ opts?: { canceledAt?: string },
32
+ ) => Promise<SubscriptionBundleRecord>;
33
+ }
34
+
35
+ export class TenantSubscriptionBundlesApiError extends Error {
36
+ constructor(
37
+ public readonly status: number,
38
+ public readonly body: unknown,
39
+ message: string,
40
+ ) {
41
+ super(message);
42
+ this.name = 'TenantSubscriptionBundlesApiError';
43
+ }
44
+ }
45
+
46
+ export function useTenantSubscriptionBundles(
47
+ options: UseTenantSubscriptionBundlesOptions,
48
+ ): UseTenantSubscriptionBundlesResult {
49
+ if (!options?.billingEndpoint) {
50
+ throw new Error(
51
+ 'useTenantSubscriptionBundles: `billingEndpoint` ist Pflicht (z. B. "/api/v1").',
52
+ );
53
+ }
54
+ const http = options.http ?? defaultHttpClient();
55
+ const baseUrl = `${options.billingEndpoint}/billing/subscription-bundles`;
56
+
57
+ const bundles = ref<SubscriptionBundleRecord[]>([]);
58
+ const loading = ref(false);
59
+ const error = ref<Error | null>(null);
60
+
61
+ function authHeaders(): Record<string, string> {
62
+ const token = options.getAuthToken?.();
63
+ return token ? { Authorization: `Bearer ${token}` } : {};
64
+ }
65
+
66
+ async function fetchJson<T>(url: string, init?: Parameters<HttpClient>[1]): Promise<T | null> {
67
+ const res = await http(url, {
68
+ method: init?.method ?? 'GET',
69
+ headers: { 'content-type': 'application/json', ...authHeaders(), ...init?.headers },
70
+ body: init?.body,
71
+ });
72
+ if (res.status === 204) return null;
73
+ const body = await res.json().catch(() => null);
74
+ if (res.status >= 400) {
75
+ throw new TenantSubscriptionBundlesApiError(
76
+ res.status,
77
+ body,
78
+ `SubscriptionBundle-API antwortete mit HTTP ${res.status}`,
79
+ );
80
+ }
81
+ return body as T;
82
+ }
83
+
84
+ async function load(): Promise<void> {
85
+ loading.value = true;
86
+ error.value = null;
87
+ try {
88
+ const data = await fetchJson<SubscriptionBundleRecord[]>(baseUrl);
89
+ bundles.value = (data ?? []).map(rehydrateDates);
90
+ } catch (err) {
91
+ error.value = err instanceof Error ? err : new Error(String(err));
92
+ } finally {
93
+ loading.value = false;
94
+ }
95
+ }
96
+
97
+ async function add(data: {
98
+ bundleVersionId: string;
99
+ minimumTermMonths?: number;
100
+ }): Promise<SubscriptionBundleRecord> {
101
+ const result = await fetchJson<SubscriptionBundleRecord>(baseUrl, {
102
+ method: 'POST',
103
+ body: JSON.stringify(data),
104
+ });
105
+ if (!result) {
106
+ throw new TenantSubscriptionBundlesApiError(0, null, 'add gab keinen Body zurück');
107
+ }
108
+ const hydrated = rehydrateDates(result);
109
+ bundles.value = [hydrated, ...bundles.value];
110
+ return hydrated;
111
+ }
112
+
113
+ async function cancel(
114
+ subscriptionBundleId: string,
115
+ opts: { canceledAt?: string } = {},
116
+ ): Promise<SubscriptionBundleRecord> {
117
+ const result = await fetchJson<SubscriptionBundleRecord>(
118
+ `${baseUrl}/${subscriptionBundleId}`,
119
+ { method: 'DELETE', body: JSON.stringify(opts) },
120
+ );
121
+ if (!result) {
122
+ throw new TenantSubscriptionBundlesApiError(0, null, 'cancel gab keinen Body zurück');
123
+ }
124
+ const hydrated = rehydrateDates(result);
125
+ bundles.value = bundles.value.map((b) => (b.id === subscriptionBundleId ? hydrated : b));
126
+ return hydrated;
127
+ }
128
+
129
+ if (options.autoLoad) void load();
130
+
131
+ return { bundles, loading, error, load, add, cancel };
132
+ }
133
+
134
+ /**
135
+ * The wire format returns an ISO string per date field; the platform type is
136
+ * `Date`. We map once at the HTTP boundary.
137
+ */
138
+ function rehydrateDates(raw: SubscriptionBundleRecord): SubscriptionBundleRecord {
139
+ return {
140
+ ...raw,
141
+ startedAt: new Date(raw.startedAt),
142
+ minimumTermEndsAt: raw.minimumTermEndsAt ? new Date(raw.minimumTermEndsAt) : null,
143
+ canceledAt: raw.canceledAt ? new Date(raw.canceledAt) : null,
144
+ canceledEffectiveAt: raw.canceledEffectiveAt ? new Date(raw.canceledEffectiveAt) : null,
145
+ createdAt: new Date(raw.createdAt),
146
+ updatedAt: new Date(raw.updatedAt),
147
+ };
148
+ }