@dev.smartpricing/platform-layer 0.0.4 → 0.0.6

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 CHANGED
@@ -38,24 +38,26 @@ pnpm add nuxt-ui-layer@"github:smartpricing/smartness-nuxt-ui#v1.6.3"
38
38
  ```ts
39
39
  // nuxt.config.ts
40
40
  export default defineNuxtConfig({
41
- extends: ['@dev.smartpricing/platform-layer'],
42
- })
41
+ extends: ["@dev.smartpricing/platform-layer"],
42
+ });
43
43
  ```
44
44
 
45
45
  ### 2. Configure Runtime Environment
46
46
 
47
47
  Set these environment variables (or define them in `nuxt.config.ts` under `runtimeConfig.public`):
48
48
 
49
- | Variable | Description | Default |
50
- |----------|-------------|---------|
51
- | `NUXT_PUBLIC_BACKEND_BASE_URL` | Backend API base URL | `''` |
52
- | `NUXT_PUBLIC_ENV_CSRF_COOKIE_NAME` | CSRF cookie name | `smt_csrf` |
53
- | `NUXT_PUBLIC_PLATFORM_URL` | Platform URL for auth redirects | `''` |
49
+ | Variable | Description | Default |
50
+ | ------------------------------ | ------------------------------- | ---------- |
51
+ | `NUXT_PUBLIC_BACKEND_BASE_URL` | Backend API base URL | `''` |
52
+ | `NUXT_PUBLIC_CSRF_COOKIE_NAME` | CSRF cookie name | `smt_csrf` |
53
+ | `NUXT_PUBLIC_ENVIRONMENT` | Environment | `local` |
54
+ | `NUXT_PUBLIC_PLATFORM_URL` | Platform URL for auth redirects | `''` |
54
55
 
55
56
  ```bash
56
57
  # .env
57
58
  NUXT_PUBLIC_BACKEND_BASE_URL=https://api.smartpricing.com
58
- NUXT_PUBLIC_ENV_CSRF_COOKIE_NAME=smt_csrf
59
+ NUXT_PUBLIC_CSRF_COOKIE_NAME=smt_csrf
60
+ ENVIRONMENT=local
59
61
  NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
60
62
  ```
61
63
 
@@ -63,13 +65,13 @@ NUXT_PUBLIC_PLATFORM_URL=https://app.smartpricing.com
63
65
 
64
66
  The layer auto-registers these [Nuxt modules](https://nuxt.com/docs/guide/concepts/modules):
65
67
 
66
- | Module | Purpose | Docs |
67
- |--------|---------|------|
68
- | [`@pinia/nuxt`](https://pinia.vuejs.org/ssr/nuxt.html) | State management | [Pinia docs](https://pinia.vuejs.org) |
69
- | [`@pinia/colada-nuxt`](https://pinia-colada.esm.dev) | Async data caching, queries & mutations | [Pinia Colada docs](https://pinia-colada.esm.dev) |
70
- | [`@nuxtjs/i18n`](https://i18n.nuxtjs.org) | Internationalization (en, it, de, es) | [Nuxt i18n docs](https://i18n.nuxtjs.org) |
71
- | [`@vueuse/nuxt`](https://vueuse.org/nuxt/README.html) | Utility composables | [VueUse docs](https://vueuse.org) |
72
- | [`nuxt-ui-layer`](https://github.com/smartpricing/smartness-nuxt-ui) | Smartness design system | — |
68
+ | Module | Purpose | Docs |
69
+ | -------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------- |
70
+ | [`@pinia/nuxt`](https://pinia.vuejs.org/ssr/nuxt.html) | State management | [Pinia docs](https://pinia.vuejs.org) |
71
+ | [`@pinia/colada-nuxt`](https://pinia-colada.esm.dev) | Async data caching, queries & mutations | [Pinia Colada docs](https://pinia-colada.esm.dev) |
72
+ | [`@nuxtjs/i18n`](https://i18n.nuxtjs.org) | Internationalization (en, it, de, es) | [Nuxt i18n docs](https://i18n.nuxtjs.org) |
73
+ | [`@vueuse/nuxt`](https://vueuse.org/nuxt/README.html) | Utility composables | [VueUse docs](https://vueuse.org) |
74
+ | [`nuxt-ui-layer`](https://github.com/smartpricing/smartness-nuxt-ui) | Smartness design system | — |
73
75
 
74
76
  All composables, queries, and mutations are **auto-imported** — no manual imports needed.
75
77
 
@@ -85,30 +87,34 @@ All composables, queries, and mutations are **auto-imported** — no manual impo
85
87
 
86
88
  ```ts
87
89
  interface ApiClientOptions {
88
- baseURL: string
89
- csrf?: ApiClientCsrfConfig
90
- auth?: ApiClientAuthConfig
91
- errorSerializer?: (context: { status: number; statusText: string; data: unknown }) => unknown
92
- onRequest?: FetchHook<FetchContext>
93
- onResponseError?: FetchHook<FetchContext & { response: Response }>
90
+ baseURL: string;
91
+ csrf?: ApiClientCsrfConfig;
92
+ auth?: ApiClientAuthConfig;
93
+ errorSerializer?: (context: {
94
+ status: number;
95
+ statusText: string;
96
+ data: unknown;
97
+ }) => unknown;
98
+ onRequest?: FetchHook<FetchContext>;
99
+ onResponseError?: FetchHook<FetchContext & { response: Response }>;
94
100
  }
95
101
  ```
96
102
 
97
- | Option | Type | Description |
98
- |--------|------|-------------|
99
- | `baseURL` | `string` | Base URL prepended to all requests. Slash handling is automatic ([ofetch docs](https://github.com/unjs/ofetch#baseurl)). |
100
- | `csrf` | `ApiClientCsrfConfig` | CSRF token injection config. |
101
- | `auth` | `ApiClientAuthConfig` | Auth config for token refresh and MFA. When omitted, returns a raw `$fetch` instance with no auth handling. |
102
- | `errorSerializer` | `(context) => unknown` | Custom error serializer. Receives `{ status, statusText, data }` from [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response). |
103
- | `onRequest` | `FetchHook<FetchContext>` | Custom [`onRequest` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) appended after built-in hooks (cookie forwarding, CSRF). |
104
- | `onResponseError` | `FetchHook<FetchContext & { response: Response }>` | Custom [`onResponseError` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) called after auth/MFA handling. |
103
+ | Option | Type | Description |
104
+ | ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
105
+ | `baseURL` | `string` | Base URL prepended to all requests. Slash handling is automatic ([ofetch docs](https://github.com/unjs/ofetch#baseurl)). |
106
+ | `csrf` | `ApiClientCsrfConfig` | CSRF token injection config. |
107
+ | `auth` | `ApiClientAuthConfig` | Auth config for token refresh and MFA. When omitted, returns a raw `$fetch` instance with no auth handling. |
108
+ | `errorSerializer` | `(context) => unknown` | Custom error serializer. Receives `{ status, statusText, data }` from [`FetchError`](https://github.com/unjs/ofetch#%EF%B8%8F-access-to-raw-response). |
109
+ | `onRequest` | `FetchHook<FetchContext>` | Custom [`onRequest` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) appended after built-in hooks (cookie forwarding, CSRF). |
110
+ | `onResponseError` | `FetchHook<FetchContext & { response: Response }>` | Custom [`onResponseError` interceptor](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors) called after auth/MFA handling. |
105
111
 
106
112
  #### CSRF Config
107
113
 
108
114
  ```ts
109
115
  interface ApiClientCsrfConfig {
110
- cookieName: string
111
- headerName?: string // default: 'X-CSRF-Token'
116
+ cookieName: string;
117
+ headerName?: string; // default: 'X-CSRF-Token'
112
118
  }
113
119
  ```
114
120
 
@@ -118,17 +124,17 @@ The client reads the CSRF token from the specified cookie via Nuxt's [`useCookie
118
124
 
119
125
  ```ts
120
126
  interface ApiClientAuthConfig {
121
- refreshBaseURL: string
122
- refreshEndpoint?: string // default: '/api/auth/refresh'
123
- platformURL: string
124
- shouldRefresh?: (status: number, data: unknown) => boolean
125
- onRefreshFailed?: () => void
126
- mfa?: ApiClientMfaConfig
127
+ refreshBaseURL: string;
128
+ refreshEndpoint?: string; // default: '/api/auth/refresh'
129
+ platformURL: string;
130
+ shouldRefresh?: (status: number, data: unknown) => boolean;
131
+ onRefreshFailed?: () => void;
132
+ mfa?: ApiClientMfaConfig;
127
133
  }
128
134
 
129
135
  interface ApiClientMfaConfig {
130
- shouldChallenge: (status: number, data: unknown) => boolean
131
- challengePath?: string // default: '/auth/mfa-challenge'
136
+ shouldChallenge: (status: number, data: unknown) => boolean;
137
+ challengePath?: string; // default: '/auth/mfa-challenge'
132
138
  }
133
139
  ```
134
140
 
@@ -148,20 +154,20 @@ On the server, the client reads the incoming request cookies via Nuxt's [`useReq
148
154
 
149
155
  ```ts
150
156
  const client = createApiClient({
151
- baseURL: 'https://api.example.com',
152
- csrf: { cookieName: 'my_csrf' },
157
+ baseURL: "https://api.example.com",
158
+ csrf: { cookieName: "my_csrf" },
153
159
  auth: {
154
- refreshBaseURL: 'https://api.example.com',
155
- platformURL: 'https://app.example.com',
156
- onRefreshFailed: () => navigateTo('/login'),
160
+ refreshBaseURL: "https://api.example.com",
161
+ platformURL: "https://app.example.com",
162
+ onRefreshFailed: () => navigateTo("/login"),
157
163
  },
158
164
  errorSerializer: ({ status, data }) => ({
159
165
  code: status,
160
- message: (data as any)?.error?.message ?? 'Unknown error',
166
+ message: (data as any)?.error?.message ?? "Unknown error",
161
167
  }),
162
- })
168
+ });
163
169
 
164
- const users = await client<User[]>('/users', { query: { active: true } })
170
+ const users = await client<User[]>("/users", { query: { active: true } });
165
171
  ```
166
172
 
167
173
  ---
@@ -206,12 +212,12 @@ const filtered = await client<User[]>('/api/users', {
206
212
 
207
213
  ```vue
208
214
  <script setup lang="ts">
209
- const client = useApiClient()
215
+ const client = useApiClient();
210
216
 
211
217
  const { data, status, error } = useQuery({
212
- key: ['custom-data'],
213
- query: () => client<MyData>('/api/custom-endpoint'),
214
- })
218
+ key: ["custom-data"],
219
+ query: () => client<MyData>("/api/custom-endpoint"),
220
+ });
215
221
  </script>
216
222
  ```
217
223
 
@@ -219,21 +225,20 @@ const { data, status, error } = useQuery({
219
225
 
220
226
  ```vue
221
227
  <script setup lang="ts">
222
- const client = useApiClient()
228
+ const client = useApiClient();
223
229
 
224
230
  const { mutateAsync, status } = useMutation({
225
231
  mutation: (body: CreateItemRequest) =>
226
- client<CreateItemResponse>('/api/items', {
227
- method: 'POST',
232
+ client<CreateItemResponse>("/api/items", {
233
+ method: "POST",
228
234
  body,
229
235
  }),
230
- })
236
+ });
231
237
 
232
238
  async function handleSubmit(data: CreateItemRequest) {
233
239
  try {
234
- const result = await mutateAsync(data)
235
- }
236
- catch (error) {
240
+ const result = await mutateAsync(data);
241
+ } catch (error) {
237
242
  // FetchError with parsed body in error.data
238
243
  }
239
244
  }
@@ -250,17 +255,17 @@ async function handleSubmit(data: CreateItemRequest) {
250
255
 
251
256
  #### Return Values
252
257
 
253
- | Property | Type | Description |
254
- |----------|------|-------------|
255
- | `signOut` | `() => Promise<void>` | Calls `POST /api/auth/logout` then redirects to `{PLATFORM_URL}/auth/login` |
256
- | `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
257
- | `logoutError` | `Ref<Error \| null>` | Error if logout failed |
258
+ | Property | Type | Description |
259
+ | -------------- | -------------------------------------------------- | --------------------------------------------------------------------------- |
260
+ | `signOut` | `() => Promise<void>` | Calls `POST /api/auth/logout` then redirects to `{PLATFORM_URL}/auth/login` |
261
+ | `logoutStatus` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | [Mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
262
+ | `logoutError` | `Ref<Error \| null>` | Error if logout failed |
258
263
 
259
264
  #### Usage
260
265
 
261
266
  ```vue
262
267
  <script setup lang="ts">
263
- const { signOut, logoutStatus } = useAuth()
268
+ const { signOut, logoutStatus } = useAuth();
264
269
  </script>
265
270
 
266
271
  <template>
@@ -280,42 +285,42 @@ Each query file also exports a **key factory** (e.g., `sessionKeys`, `billingOve
280
285
 
281
286
  ### Session & User
282
287
 
283
- | Composable | Endpoint | Response Type | Key Factory |
284
- |------------|----------|---------------|-------------|
285
- | `useSessionQuery()` | `GET /api/me` | `MeContextResponse` | `sessionKeys.me()` |
286
- | `usePermissionsQuery()` | `GET /api/me/permissions` | `PermissionsResponse` | `permissionsKeys.all()` |
288
+ | Composable | Endpoint | Response Type | Key Factory |
289
+ | ------------------------ | --------------------------- | ------------------------- | ------------------------ |
290
+ | `useSessionQuery()` | `GET /api/me` | `MeContextResponse` | `sessionKeys.me()` |
291
+ | `usePermissionsQuery()` | `GET /api/me/permissions` | `PermissionsResponse` | `permissionsKeys.all()` |
287
292
  | `useCrossSellingQuery()` | `GET /api/me/cross-selling` | `GetCrossSellingResponse` | `crossSellingKeys.all()` |
288
293
 
289
294
  ### Organization
290
295
 
291
- | Composable | Endpoint | Response Type | Key Factory |
292
- |------------|----------|---------------|-------------|
296
+ | Composable | Endpoint | Response Type | Key Factory |
297
+ | ----------------------------- | ----------------------- | ----------------------------- | ------------------------------ |
293
298
  | `useOrganizationQuery(orgId)` | `GET /api/orgs/{orgId}` | `OrganizationContextResponse` | `organizationKeys.byId(orgId)` |
294
299
 
295
300
  **Params:** `orgId: MaybeRefOrGetter<string>` — enabled only when `orgId` is truthy.
296
301
 
297
302
  ### Billing
298
303
 
299
- | Composable | Endpoint | Response Type | Key Factory |
300
- |------------|----------|---------------|-------------|
301
- | `useBillingOverviewQuery(orgId)` | `GET /api/orgs/{orgId}/billing` | `BillingOverviewResponse` | `billingOverviewKeys.byOrg(orgId)` |
302
- | `useBillingDocumentsQuery(orgId, params?)` | `GET /api/orgs/{orgId}/billing/documents` | `ListBillingDocumentsResponse` | `billingDocumentsKeys.byOrg(orgId, params)` |
303
- | `useLegalEntityQuery(orgId, id)` | `GET /api/orgs/{orgId}/billing/legal-entities/{id}` | `LegalEntityDetail` | `legalEntityKeys.byId(orgId, id)` |
304
- | `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse` | `billingDocumentPdfKeys.byId(orgId, type, id)` |
304
+ | Composable | Endpoint | Response Type | Key Factory |
305
+ | --------------------------------------------- | --------------------------------------------------------- | ------------------------------ | ---------------------------------------------- |
306
+ | `useBillingOverviewQuery(orgId)` | `GET /api/orgs/{orgId}/billing` | `BillingOverviewResponse` | `billingOverviewKeys.byOrg(orgId)` |
307
+ | `useBillingDocumentsQuery(orgId, params?)` | `GET /api/orgs/{orgId}/billing/documents` | `ListBillingDocumentsResponse` | `billingDocumentsKeys.byOrg(orgId, params)` |
308
+ | `useLegalEntityQuery(orgId, id)` | `GET /api/orgs/{orgId}/billing/legal-entities/{id}` | `LegalEntityDetail` | `legalEntityKeys.byId(orgId, id)` |
309
+ | `useBillingDocumentPdfQuery(orgId, type, id)` | `GET /api/orgs/{orgId}/billing/documents/{type}/{id}/pdf` | `BillingDocumentPdfResponse` | `billingDocumentPdfKeys.byId(orgId, type, id)` |
305
310
 
306
311
  #### Billing Documents Filter Params
307
312
 
308
313
  ```ts
309
314
  interface BillingDocumentsQueryParams {
310
- legalEntityId?: string
311
- customerId?: string
312
- limit?: number
313
- after?: string
314
- before?: string
315
- subscriptionIds?: string[]
316
- type?: string
317
- status?: string
318
- search?: string
315
+ legalEntityId?: string;
316
+ customerId?: string;
317
+ limit?: number;
318
+ after?: string;
319
+ before?: string;
320
+ subscriptionIds?: string[];
321
+ type?: string;
322
+ status?: string;
323
+ search?: string;
319
324
  }
320
325
  ```
321
326
 
@@ -323,20 +328,20 @@ interface BillingDocumentsQueryParams {
323
328
 
324
329
  ```vue
325
330
  <script setup lang="ts">
326
- const route = useRoute()
331
+ const route = useRoute();
327
332
 
328
333
  // Static query — fetches immediately
329
- const { data: session, status } = useSessionQuery()
334
+ const { data: session, status } = useSessionQuery();
330
335
 
331
336
  // Dynamic query — re-fetches when orgId changes
332
- const { data: org } = useOrganizationQuery(() => route.params.orgId as string)
337
+ const { data: org } = useOrganizationQuery(() => route.params.orgId as string);
333
338
 
334
339
  // Conditional query with filter params
335
- const filters = ref<BillingDocumentsQueryParams>({ limit: 20 })
340
+ const filters = ref<BillingDocumentsQueryParams>({ limit: 20 });
336
341
  const { data: docs } = useBillingDocumentsQuery(
337
342
  () => route.params.orgId as string,
338
343
  filters,
339
- )
344
+ );
340
345
  </script>
341
346
 
342
347
  <template>
@@ -352,16 +357,16 @@ Use the exported key factories with [`useQueryCache()`](https://pinia-colada.esm
352
357
 
353
358
  ```vue
354
359
  <script setup lang="ts">
355
- import { useQueryCache } from '@pinia/colada'
360
+ import { useQueryCache } from "@pinia/colada";
356
361
 
357
- const queryCache = useQueryCache()
362
+ const queryCache = useQueryCache();
358
363
 
359
- const { mutateAsync: updateProfile } = useUpdateProfileMutation()
364
+ const { mutateAsync: updateProfile } = useUpdateProfileMutation();
360
365
 
361
366
  async function handleSave(data: UpdateProfileRequest) {
362
- await updateProfile(data)
367
+ await updateProfile(data);
363
368
  // Invalidate session to refetch updated user data
364
- queryCache.invalidateQueries({ key: sessionKeys.me() })
369
+ queryCache.invalidateQueries({ key: sessionKeys.me() });
365
370
  }
366
371
  </script>
367
372
  ```
@@ -372,62 +377,62 @@ async function handleSave(data: UpdateProfileRequest) {
372
377
 
373
378
  All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations.html) from Pinia Colada. Each returns:
374
379
 
375
- | Property | Type | Description |
376
- |----------|------|-------------|
377
- | `mutate` | `(params) => void` | Fire-and-forget — errors handled via `onError` hook |
378
- | `mutateAsync` | `(params) => Promise<TResult>` | Returns promise — use `try/catch` for errors |
379
- | `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Overall [mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
380
- | `asyncStatus` | `Ref<'idle' \| 'loading'>` | Whether a request is in-flight |
381
- | `data` | `Ref<TResult \| undefined>` | Last successful response |
382
- | `error` | `Ref<Error \| null>` | Last error |
383
- | `variables` | `Ref<TParams \| undefined>` | Last mutation parameters |
384
- | `reset` | `() => void` | Reset to initial state |
380
+ | Property | Type | Description |
381
+ | ------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
382
+ | `mutate` | `(params) => void` | Fire-and-forget — errors handled via `onError` hook |
383
+ | `mutateAsync` | `(params) => Promise<TResult>` | Returns promise — use `try/catch` for errors |
384
+ | `status` | `Ref<'idle' \| 'pending' \| 'success' \| 'error'>` | Overall [mutation status](https://pinia-colada.esm.dev/guide/mutations.html) |
385
+ | `asyncStatus` | `Ref<'idle' \| 'loading'>` | Whether a request is in-flight |
386
+ | `data` | `Ref<TResult \| undefined>` | Last successful response |
387
+ | `error` | `Ref<Error \| null>` | Last error |
388
+ | `variables` | `Ref<TParams \| undefined>` | Last mutation parameters |
389
+ | `reset` | `() => void` | Reset to initial state |
385
390
 
386
391
  ### Authentication Mutations
387
392
 
388
- | Composable | Method | Endpoint | Request | Response |
389
- |------------|--------|----------|---------|----------|
390
- | `useLoginMutation()` | `POST` | `/api/auth/login` | `LoginRequest` | `void` |
391
- | `useLogoutMutation()` | `POST` | `/api/auth/logout` | — | `void` |
392
- | `useRefreshMutation()` | `POST` | `/api/auth/refresh` | — | `void` |
393
- | `useActivateMutation()` | `POST` | `/api/auth/activate` | `ActivateRequest` | `void` |
394
- | `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void` |
393
+ | Composable | Method | Endpoint | Request | Response |
394
+ | ------------------------------ | ------ | ---------------------------- | ------------------------ | -------- |
395
+ | `useLoginMutation()` | `POST` | `/api/auth/login` | `LoginRequest` | `void` |
396
+ | `useLogoutMutation()` | `POST` | `/api/auth/logout` | — | `void` |
397
+ | `useRefreshMutation()` | `POST` | `/api/auth/refresh` | — | `void` |
398
+ | `useActivateMutation()` | `POST` | `/api/auth/activate` | `ActivateRequest` | `void` |
399
+ | `useAccountingLoginMutation()` | `POST` | `/api/auth/accounting-login` | `AccountingLoginRequest` | `void` |
395
400
 
396
401
  ### MFA Mutations
397
402
 
398
- | Composable | Method | Endpoint | Request | Response |
399
- |------------|--------|----------|---------|----------|
400
- | `useMfaSetupMutation()` | `POST` | `/api/auth/mfa/setup` | — | `MfaSetupResponse` |
401
- | `useMfaStepUpMutation()` | `POST` | `/api/auth/otp` | `MfaStepUpRequest` | `MfaStepUpResponse` |
402
- | `useMfaFinalizeMutation()` | `POST` | `/api/auth/mfa/finalize` | `MfaFinalizeRequest` | `MfaFinalizeResponse` |
403
- | `useMfaDisableMutation()` | `POST` | `/api/auth/mfa/disable` | `MfaDisableRequest` | `MfaDisableResponse` |
403
+ | Composable | Method | Endpoint | Request | Response |
404
+ | ----------------------------------------- | ------ | ----------------------------------------- | ----------------------------------- | ------------------------------------ |
405
+ | `useMfaSetupMutation()` | `POST` | `/api/auth/mfa/setup` | — | `MfaSetupResponse` |
406
+ | `useMfaStepUpMutation()` | `POST` | `/api/auth/otp` | `MfaStepUpRequest` | `MfaStepUpResponse` |
407
+ | `useMfaFinalizeMutation()` | `POST` | `/api/auth/mfa/finalize` | `MfaFinalizeRequest` | `MfaFinalizeResponse` |
408
+ | `useMfaDisableMutation()` | `POST` | `/api/auth/mfa/disable` | `MfaDisableRequest` | `MfaDisableResponse` |
404
409
  | `useMfaRegenerateRecoveryCodesMutation()` | `POST` | `/api/auth/mfa/regenerate-recovery-codes` | `MfaRegenerateRecoveryCodesRequest` | `MfaRegenerateRecoveryCodesResponse` |
405
410
 
406
411
  ### Profile Mutations
407
412
 
408
- | Composable | Method | Endpoint | Request | Response |
409
- |------------|--------|----------|---------|----------|
410
- | `useUpdateProfileMutation()` | `PATCH` | `/api/me` | `UpdateProfileRequest` | `UpdateProfileResponse` |
411
- | `useChangePasswordMutation()` | `POST` | `/api/me/change-password` | `ChangePasswordRequest` | `{ ok: true }` |
412
- | `useRequestPasswordResetMutation()` | `POST` | `/api/auth/request-password-reset` | `ForgotPasswordRequest` | `void` |
413
- | `useResetPasswordMutation()` | `POST` | `/api/auth/reset-password` | `ResetPasswordRequest` | `void` |
413
+ | Composable | Method | Endpoint | Request | Response |
414
+ | ----------------------------------- | ------- | ---------------------------------- | ----------------------- | ----------------------- |
415
+ | `useUpdateProfileMutation()` | `PATCH` | `/api/me` | `UpdateProfileRequest` | `UpdateProfileResponse` |
416
+ | `useChangePasswordMutation()` | `POST` | `/api/me/change-password` | `ChangePasswordRequest` | `{ ok: true }` |
417
+ | `useRequestPasswordResetMutation()` | `POST` | `/api/auth/request-password-reset` | `ForgotPasswordRequest` | `void` |
418
+ | `useResetPasswordMutation()` | `POST` | `/api/auth/reset-password` | `ResetPasswordRequest` | `void` |
414
419
 
415
420
  ### Billing Mutations
416
421
 
417
- | Composable | Method | Endpoint | Request | Response |
418
- |------------|--------|----------|---------|----------|
419
- | `useCreatePaymentMethodMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods` | `{ orgId, body: CreatePaymentMethodRequest }` | `CreatePaymentMethodsResponse` |
420
- | `useSetPrimaryPaymentMethodMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/payment-methods/primary` | `{ orgId, body: SetPrimaryPaymentMethodRequest }` | `SetPrimaryPaymentMethodResponse` |
421
- | `useRemovePaymentMethodMutation()` | `DELETE` | `/api/orgs/{orgId}/billing/payment-methods/{paymentMethodId}` | `{ orgId, paymentMethodId }` | `void` |
422
- | `useCreateSetupIntentMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods/setup-intent` | `{ orgId, body: CreateSetupIntentRequest }` | `CreateSetupIntentResponse` |
422
+ | Composable | Method | Endpoint | Request | Response |
423
+ | -------------------------------------- | -------- | ------------------------------------------------------------- | ------------------------------------------------- | --------------------------------- |
424
+ | `useCreatePaymentMethodMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods` | `{ orgId, body: CreatePaymentMethodRequest }` | `CreatePaymentMethodsResponse` |
425
+ | `useSetPrimaryPaymentMethodMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/payment-methods/primary` | `{ orgId, body: SetPrimaryPaymentMethodRequest }` | `SetPrimaryPaymentMethodResponse` |
426
+ | `useRemovePaymentMethodMutation()` | `DELETE` | `/api/orgs/{orgId}/billing/payment-methods/{paymentMethodId}` | `{ orgId, paymentMethodId }` | `void` |
427
+ | `useCreateSetupIntentMutation()` | `POST` | `/api/orgs/{orgId}/billing/payment-methods/setup-intent` | `{ orgId, body: CreateSetupIntentRequest }` | `CreateSetupIntentResponse` |
423
428
 
424
429
  ### Organization Mutations
425
430
 
426
- | Composable | Method | Endpoint | Request | Response |
427
- |------------|--------|----------|---------|----------|
428
- | `useUpdateLegalEntityMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/legal-entities/{id}` | `{ orgId, id, body: UpdateLegalEntityRequest }` | `LegalEntityDetail` |
429
- | `useRequestLegalEntityChangeMutation()` | `POST` | `/api/orgs/{orgId}/billing/legal-entities/{id}/change-request` | `{ orgId, id, body: RequestLegalEntityChangeRequest }` | `{ emails_status: 'sent' \| 'failed' }` |
430
- | `useRequestSubscriptionCancellationMutation()` | `POST` | `/api/orgs/{orgId}/subscriptions/request-cancellation` | `{ orgId, body: SubscriptionCancellationRequest }` | `SubscriptionCancellationResponse` |
431
+ | Composable | Method | Endpoint | Request | Response |
432
+ | ---------------------------------------------- | ------- | -------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------- |
433
+ | `useUpdateLegalEntityMutation()` | `PATCH` | `/api/orgs/{orgId}/billing/legal-entities/{id}` | `{ orgId, id, body: UpdateLegalEntityRequest }` | `LegalEntityDetail` |
434
+ | `useRequestLegalEntityChangeMutation()` | `POST` | `/api/orgs/{orgId}/billing/legal-entities/{id}/change-request` | `{ orgId, id, body: RequestLegalEntityChangeRequest }` | `{ emails_status: 'sent' \| 'failed' }` |
435
+ | `useRequestSubscriptionCancellationMutation()` | `POST` | `/api/orgs/{orgId}/subscriptions/request-cancellation` | `{ orgId, body: SubscriptionCancellationRequest }` | `SubscriptionCancellationResponse` |
431
436
 
432
437
  ### Mutation Usage Examples
433
438
 
@@ -435,14 +440,13 @@ All mutations use [`useMutation()`](https://pinia-colada.esm.dev/guide/mutations
435
440
 
436
441
  ```vue
437
442
  <script setup lang="ts">
438
- const { mutateAsync: login, status, error } = useLoginMutation()
443
+ const { mutateAsync: login, status, error } = useLoginMutation();
439
444
 
440
445
  async function handleLogin(email: string, password: string) {
441
446
  try {
442
- await login({ email, password })
443
- await navigateTo('/dashboard')
444
- }
445
- catch (err) {
447
+ await login({ email, password });
448
+ await navigateTo("/dashboard");
449
+ } catch (err) {
446
450
  // error.value is also set reactively
447
451
  }
448
452
  }
@@ -452,7 +456,7 @@ async function handleLogin(email: string, password: string) {
452
456
  <form @submit.prevent="handleLogin(email, password)">
453
457
  <p v-if="error">{{ error.message }}</p>
454
458
  <button :disabled="status === 'pending'" type="submit">
455
- {{ status === 'pending' ? 'Signing in...' : 'Sign In' }}
459
+ {{ status === "pending" ? "Signing in..." : "Sign In" }}
456
460
  </button>
457
461
  </form>
458
462
  </template>
@@ -462,14 +466,14 @@ async function handleLogin(email: string, password: string) {
462
466
 
463
467
  ```vue
464
468
  <script setup lang="ts">
465
- const { mutateAsync: setupMfa } = useMfaSetupMutation()
466
- const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation()
469
+ const { mutateAsync: setupMfa } = useMfaSetupMutation();
470
+ const { mutateAsync: finalizeMfa } = useMfaFinalizeMutation();
467
471
 
468
472
  // Step 1: Get QR code / secret
469
- const setupData = await setupMfa()
473
+ const setupData = await setupMfa();
470
474
 
471
475
  // Step 2: User enters TOTP code, finalize
472
- await finalizeMfa({ otp: userCode })
476
+ await finalizeMfa({ otp: userCode });
473
477
  </script>
474
478
  ```
475
479
 
@@ -477,21 +481,22 @@ await finalizeMfa({ otp: userCode })
477
481
 
478
482
  ```vue
479
483
  <script setup lang="ts">
480
- import { useQueryCache } from '@pinia/colada'
484
+ import { useQueryCache } from "@pinia/colada";
481
485
 
482
- const queryCache = useQueryCache()
483
- const orgId = computed(() => route.params.orgId as string)
486
+ const queryCache = useQueryCache();
487
+ const orgId = computed(() => route.params.orgId as string);
484
488
 
485
- const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation()
489
+ const { mutateAsync: removeMethod, status } = useRemovePaymentMethodMutation();
486
490
 
487
491
  async function handleRemove(paymentMethodId: string) {
488
492
  try {
489
- await removeMethod({ orgId: orgId.value, paymentMethodId })
493
+ await removeMethod({ orgId: orgId.value, paymentMethodId });
490
494
 
491
495
  // Refetch billing data after removing payment method
492
- queryCache.invalidateQueries({ key: billingOverviewKeys.byOrg(orgId.value) })
493
- }
494
- catch (err) {
496
+ queryCache.invalidateQueries({
497
+ key: billingOverviewKeys.byOrg(orgId.value),
498
+ });
499
+ } catch (err) {
495
500
  // Handle error
496
501
  }
497
502
  }
@@ -502,17 +507,16 @@ async function handleRemove(paymentMethodId: string) {
502
507
 
503
508
  ```vue
504
509
  <script setup lang="ts">
505
- const { mutate, mutateAsync, status, error } = useUpdateProfileMutation()
510
+ const { mutate, mutateAsync, status, error } = useUpdateProfileMutation();
506
511
 
507
512
  // Fire-and-forget — errors are swallowed, check error ref reactively
508
- mutate({ name: 'New Name' })
513
+ mutate({ name: "New Name" });
509
514
 
510
515
  // Async — errors rethrown, use try/catch
511
516
  try {
512
- const result = await mutateAsync({ name: 'New Name' })
513
- }
514
- catch (err) {
515
- console.error('Update failed:', err)
517
+ const result = await mutateAsync({ name: "New Name" });
518
+ } catch (err) {
519
+ console.error("Update failed:", err);
516
520
  }
517
521
  </script>
518
522
  ```
@@ -530,10 +534,10 @@ If your app already provides a module the layer includes, [disable it](https://n
530
534
  ```ts
531
535
  // nuxt.config.ts
532
536
  export default defineNuxtConfig({
533
- extends: ['@dev.smartpricing/platform-layer'],
534
- i18n: false, // disable layer's i18n
535
- pinia: false, // disable layer's pinia
536
- })
537
+ extends: ["@dev.smartpricing/platform-layer"],
538
+ i18n: false, // disable layer's i18n
539
+ pinia: false, // disable layer's pinia
540
+ });
537
541
  ```
538
542
 
539
543
  ### Override i18n Locales
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Static geo/billing lists for the legal-entity form dropdowns.
3
+ *
4
+ * The three code lists mirror the PostgreSQL enums core-api enforces on
5
+ * `legal_entities` (see `core-api/packages/db-schema/src/schema/enums.ts`:
6
+ * `iso_a2_country_code`, `vat_number_prefix_type`, `iso_phone_country_code`).
7
+ * Anything outside these lists is rejected server-side with a 400, so the
8
+ * UI must not offer it. Keep them in sync when core-api adds values.
9
+ */
10
+
11
+ /** ISO 3166-1 alpha-2 billing countries (`iso_a2_country_code`). Note "IC"
12
+ * (Canary Islands) is a tax territory with no `Intl.DisplayNames` name —
13
+ * label it via the fallback. */
14
+ export const ISO_A2_COUNTRY_CODES = [
15
+ 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
16
+ 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS',
17
+ 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN',
18
+ 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE',
19
+ 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF',
20
+ 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM',
21
+ 'HN', 'HR', 'HT', 'HU', 'IC', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE',
22
+ 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB',
23
+ 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH',
24
+ 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ',
25
+ 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF',
26
+ 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU',
27
+ 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR',
28
+ 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN',
29
+ 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG',
30
+ 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
31
+ ] as const
32
+
33
+ /** VAT-number prefixes (`vat_number_prefix_type`): the country list plus the
34
+ * VAT-only codes "EL" (Greece) and "XI" (Northern Ireland). */
35
+ export const VAT_NUMBER_PREFIXES = [
36
+ 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ',
37
+ 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS',
38
+ 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN',
39
+ 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE',
40
+ 'EG', 'EH', 'EL', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE',
41
+ 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK',
42
+ 'HM', 'HN', 'HR', 'HT', 'HU', 'IC', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT',
43
+ 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA',
44
+ 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG',
45
+ 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY',
46
+ 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE',
47
+ 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS',
48
+ 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO',
49
+ 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM',
50
+ 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE',
51
+ 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'XI', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
52
+ ] as const
53
+
54
+ /** International calling codes, digits only — exactly the values
55
+ * `iso_phone_country_code` accepts ("+39" is rejected server-side). */
56
+ export const PHONE_COUNTRY_CODES = [
57
+ '1', '7', '20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43', '44', '45',
58
+ '46', '47', '48', '49', '51', '52', '53', '54', '55', '56', '57', '58', '60', '61', '62', '63',
59
+ '64', '65', '66', '81', '82', '84', '86', '90', '91', '92', '93', '94', '95', '98', '211', '212',
60
+ '213', '216', '218', '220', '221', '222', '223', '224', '225', '226', '227', '228', '229', '230',
61
+ '231', '232', '233', '234', '235', '236', '237', '238', '239', '240', '241', '242', '243', '244',
62
+ '245', '246', '247', '248', '249', '250', '251', '252', '253', '254', '255', '256', '257', '258',
63
+ '260', '261', '262', '263', '264', '265', '266', '267', '268', '269', '290', '291', '297', '298',
64
+ '299', '350', '351', '352', '353', '354', '355', '356', '357', '358', '359', '370', '371', '372',
65
+ '373', '374', '375', '376', '377', '378', '380', '381', '382', '383', '385', '386', '387', '389',
66
+ '420', '421', '423', '500', '501', '502', '503', '504', '505', '506', '507', '508', '509', '590',
67
+ '591', '592', '593', '594', '595', '596', '597', '598', '599', '670', '672', '673', '674', '675',
68
+ '676', '677', '678', '679', '680', '681', '682', '683', '685', '686', '687', '688', '689', '690',
69
+ '691', '692', '850', '852', '853', '855', '856', '880', '886', '960', '961', '962', '963', '964',
70
+ '965', '966', '967', '968', '970', '971', '972', '973', '974', '975', '976', '977', '992', '993',
71
+ '994', '995', '996', '998',
72
+ ] as const
73
+
74
+ /**
75
+ * Representative ISO region per calling code, used ONLY for dropdown labels
76
+ * ("+39 · Italia"). Shared codes show their main region (libphonenumber's
77
+ * "main country for code": +1 → US, +7 → RU, ...) without enumerating the
78
+ * others — the stored value is the calling code, so the ambiguity is purely
79
+ * cosmetic. "AC" (+247) and "XK" (+383) are not ISO 3166 codes and fall back
80
+ * to the bare code in `Intl.DisplayNames` lookups.
81
+ */
82
+ export const PHONE_CODE_PRIMARY_REGION: Record<string, string> = {
83
+ 1: 'US', 7: 'RU', 20: 'EG', 27: 'ZA', 30: 'GR', 31: 'NL', 32: 'BE', 33: 'FR', 34: 'ES',
84
+ 36: 'HU', 39: 'IT', 40: 'RO', 41: 'CH', 43: 'AT', 44: 'GB', 45: 'DK', 46: 'SE', 47: 'NO',
85
+ 48: 'PL', 49: 'DE', 51: 'PE', 52: 'MX', 53: 'CU', 54: 'AR', 55: 'BR', 56: 'CL', 57: 'CO',
86
+ 58: 'VE', 60: 'MY', 61: 'AU', 62: 'ID', 63: 'PH', 64: 'NZ', 65: 'SG', 66: 'TH', 81: 'JP',
87
+ 82: 'KR', 84: 'VN', 86: 'CN', 90: 'TR', 91: 'IN', 92: 'PK', 93: 'AF', 94: 'LK', 95: 'MM',
88
+ 98: 'IR', 211: 'SS', 212: 'MA', 213: 'DZ', 216: 'TN', 218: 'LY', 220: 'GM', 221: 'SN',
89
+ 222: 'MR', 223: 'ML', 224: 'GN', 225: 'CI', 226: 'BF', 227: 'NE', 228: 'TG', 229: 'BJ',
90
+ 230: 'MU', 231: 'LR', 232: 'SL', 233: 'GH', 234: 'NG', 235: 'TD', 236: 'CF', 237: 'CM',
91
+ 238: 'CV', 239: 'ST', 240: 'GQ', 241: 'GA', 242: 'CG', 243: 'CD', 244: 'AO', 245: 'GW',
92
+ 246: 'IO', 247: 'AC', 248: 'SC', 249: 'SD', 250: 'RW', 251: 'ET', 252: 'SO', 253: 'DJ',
93
+ 254: 'KE', 255: 'TZ', 256: 'UG', 257: 'BI', 258: 'MZ', 260: 'ZM', 261: 'MG', 262: 'RE',
94
+ 263: 'ZW', 264: 'NA', 265: 'MW', 266: 'LS', 267: 'BW', 268: 'SZ', 269: 'KM', 290: 'SH',
95
+ 291: 'ER', 297: 'AW', 298: 'FO', 299: 'GL', 350: 'GI', 351: 'PT', 352: 'LU', 353: 'IE',
96
+ 354: 'IS', 355: 'AL', 356: 'MT', 357: 'CY', 358: 'FI', 359: 'BG', 370: 'LT', 371: 'LV',
97
+ 372: 'EE', 373: 'MD', 374: 'AM', 375: 'BY', 376: 'AD', 377: 'MC', 378: 'SM', 380: 'UA',
98
+ 381: 'RS', 382: 'ME', 383: 'XK', 385: 'HR', 386: 'SI', 387: 'BA', 389: 'MK', 420: 'CZ',
99
+ 421: 'SK', 423: 'LI', 500: 'FK', 501: 'BZ', 502: 'GT', 503: 'SV', 504: 'HN', 505: 'NI',
100
+ 506: 'CR', 507: 'PA', 508: 'PM', 509: 'HT', 590: 'GP', 591: 'BO', 592: 'GY', 593: 'EC',
101
+ 594: 'GF', 595: 'PY', 596: 'MQ', 597: 'SR', 598: 'UY', 599: 'CW', 670: 'TL', 672: 'NF',
102
+ 673: 'BN', 674: 'NR', 675: 'PG', 676: 'TO', 677: 'SB', 678: 'VU', 679: 'FJ', 680: 'PW',
103
+ 681: 'WF', 682: 'CK', 683: 'NU', 685: 'WS', 686: 'KI', 687: 'NC', 688: 'TV', 689: 'PF',
104
+ 690: 'TK', 691: 'FM', 692: 'MH', 850: 'KP', 852: 'HK', 853: 'MO', 855: 'KH', 856: 'LA',
105
+ 880: 'BD', 886: 'TW', 960: 'MV', 961: 'LB', 962: 'JO', 963: 'SY', 964: 'IQ', 965: 'KW',
106
+ 966: 'SA', 967: 'YE', 968: 'OM', 970: 'PS', 971: 'AE', 972: 'IL', 973: 'BH', 974: 'QA',
107
+ 975: 'BT', 976: 'MN', 977: 'NP', 992: 'TJ', 993: 'TM', 994: 'AZ', 995: 'GE', 996: 'KG',
108
+ 998: 'UZ',
109
+ }
110
+
111
+ /**
112
+ * Countries whose VAT prefix differs from their ISO code. Same mapping as
113
+ * core-api (`packages/chargebee-schema/src/customers.ts`) and
114
+ * sp-checkout-frontend: Greece uses "EL" on VATINs.
115
+ */
116
+ export const COUNTRY_TO_VAT_PREFIX: Record<string, string> = { GR: 'EL' }
117
+
118
+ /** Derives the VAT prefix to preselect for a billing country (GR → EL). */
119
+ export function countryToVatPrefix(country: string | null | undefined): string | null {
120
+ if (!country) return null
121
+ return COUNTRY_TO_VAT_PREFIX[country] ?? country
122
+ }
123
+
124
+ /**
125
+ * Normalizes a user-typed phone prefix to the digits-only form the core-api
126
+ * enum accepts: strips "+", spaces and any other non-digit ("+39 " → "39").
127
+ * Returns the digits, or '' when nothing is left.
128
+ */
129
+ export function normalizePhonePrefix(input: string): string {
130
+ return input.replace(/\D/g, '')
131
+ }
132
+
133
+ /**
134
+ * E-invoice routing-code (codice destinatario / `cf_PR_address_code`)
135
+ * placeholders, mirroring core-api's enforcement matrix
136
+ * (`normalizeEinvoiceRoutingCodeOrThrow` in `legal-entities/service.ts`):
137
+ * - non-IT (any type) → "XXXXXXX"
138
+ * - IT natural_person → "0000000"
139
+ * - IT legal_person → any 7-char alphanumeric SDI code ("0000000"
140
+ * when invoices are delivered via PEC)
141
+ */
142
+ export const IT_EINVOICE_PLACEHOLDER = '0000000'
143
+ export const NON_IT_EINVOICE_PLACEHOLDER = 'XXXXXXX'
144
+ export const SDI_CODE_REGEX = /^[A-Za-z0-9]{7}$/
145
+
146
+ /**
147
+ * The locked routing-code value for a country/entity-type pair, or null when
148
+ * the field is freely editable (Italian businesses).
149
+ */
150
+ export function fixedEinvoiceRoutingCode(
151
+ countryCode: string | null | undefined,
152
+ entityType: 'natural_person' | 'legal_person' | null | undefined,
153
+ ): string | null {
154
+ if (!countryCode || !entityType) return null
155
+ if (countryCode !== 'IT') return NON_IT_EINVOICE_PLACEHOLDER
156
+ if (entityType === 'natural_person') return IT_EINVOICE_PLACEHOLDER
157
+ return null
158
+ }
@@ -1,6 +1,29 @@
1
1
  import { z } from 'zod'
2
+ import {
3
+ ISO_A2_COUNTRY_CODES,
4
+ normalizePhonePrefix,
5
+ PHONE_COUNTRY_CODES,
6
+ SDI_CODE_REGEX,
7
+ VAT_NUMBER_PREFIXES,
8
+ } from './billingGeo.js'
2
9
  import { UuidSchema } from './common.js'
3
10
 
11
+ const COUNTRY_CODE_SET = new Set<string>(ISO_A2_COUNTRY_CODES)
12
+ const VAT_PREFIX_SET = new Set<string>(VAT_NUMBER_PREFIXES)
13
+ const PHONE_CODE_SET = new Set<string>(PHONE_COUNTRY_CODES)
14
+
15
+ /** "+39 " → "39", then membership in core-api's `iso_phone_country_code` enum. */
16
+ const PhoneCountryCodeSchema = z
17
+ .string()
18
+ .transform(normalizePhonePrefix)
19
+ .refine(v => PHONE_CODE_SET.has(v), { message: 'Unknown phone country code' })
20
+ const CountryCodeSchema = z
21
+ .string()
22
+ .refine(v => COUNTRY_CODE_SET.has(v), { message: 'Unknown country code' })
23
+ const VatNumberPrefixSchema = z
24
+ .string()
25
+ .refine(v => VAT_PREFIX_SET.has(v), { message: 'Unknown VAT number prefix' })
26
+
4
27
  export const LegalEntityTypeSchema = z.enum(['natural_person', 'legal_person'])
5
28
 
6
29
  /**
@@ -54,6 +77,12 @@ export const LegalEntityDetailSchema = z.object({
54
77
  einvoiceRoutingCode: z.string(),
55
78
  createdAt: z.string(),
56
79
  updatedAt: z.string(),
80
+ /**
81
+ * PATCH responses only: true when Chargebee/VIES rejected the VAT and
82
+ * core-api stored it as "unverified" instead of the validated field. The
83
+ * edit modal surfaces this as a warning toast.
84
+ */
85
+ vatNumberUnverified: z.boolean().optional(),
57
86
  })
58
87
 
59
88
  /**
@@ -68,17 +97,21 @@ export const UpdateLegalEntityRequestSchema = z.object({
68
97
  companyName: z.string().optional(),
69
98
  email: z.string().email().optional(),
70
99
  pec: z.string().nullable().optional(),
71
- phoneCountryCode: z.string().nullable().optional(),
100
+ phoneCountryCode: PhoneCountryCodeSchema.nullable().optional(),
72
101
  phone: z.string().nullable().optional(),
73
102
  address: z.string().optional(),
74
103
  postcode: z.string().optional(),
75
104
  city: z.string().optional(),
76
- state: z.string().optional(),
77
- countryCode: z.string().optional(),
78
- vatNumberPrefix: z.string().optional(),
105
+ // Always the 2-character state/province code (TN, NY, 75, …) — core-api
106
+ // rejects anything else and uppercases on write.
107
+ state: z.string().regex(/^[A-Za-z0-9]{2}$/).optional(),
108
+ countryCode: CountryCodeSchema.optional(),
109
+ vatNumberPrefix: VatNumberPrefixSchema.optional(),
79
110
  vatNumber: z.string().optional(),
80
111
  taxCode: z.string().optional(),
81
- einvoiceRoutingCode: z.string().optional(),
112
+ // 7 alphanumeric chars: real SDI codes and both placeholders ("0000000",
113
+ // "XXXXXXX") pass; core-api enforces the full country/type matrix.
114
+ einvoiceRoutingCode: z.string().regex(SDI_CODE_REGEX).optional(),
82
115
  })
83
116
 
84
117
  /**
@@ -74,7 +74,13 @@ export const CreatePaymentMethodRequestSchema = z.discriminatedUnion('type', [
74
74
  /** One item for a card, one per customer for the IBAN fan-out, plus per-customer failures. */
75
75
  export const CreatePaymentMethodsResponseSchema = z.object({
76
76
  items: z.array(PaymentMethodSchema),
77
- failed: z.array(z.object({ customerId: UuidSchema, reason: z.string() })),
77
+ failed: z.array(z.object({
78
+ customerId: UuidSchema,
79
+ /** Provider message for logs/ops — never shown to the customer. */
80
+ reason: z.string(),
81
+ /** Machine-readable failure code (Chargebee api_error_code) the UI maps to localized copy. Optional during core-api rollout. */
82
+ code: z.string().optional(),
83
+ })),
78
84
  })
79
85
 
80
86
  export type PaymentMethodType = z.infer<typeof PaymentMethodTypeSchema>
package/_shared/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from './billingInvoices.js'
14
14
  export * from './billingCreditNotes.js'
15
15
  export * from './billingPaymentMethods.js'
16
16
  export * from './billingLegalEntities.js'
17
+ export * from './billingGeo.js'
17
18
  export * from './billingOverview.js'
18
19
  export * from './billingDocuments.js'
19
20
  export * from './crossSelling.js'
@@ -1,7 +1,14 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  const BundleSchema = z.object({
4
+ /** Has an active or trial billing subscription. */
5
+ subscribed: z.boolean(),
6
+ /** Lifecycle product_access capability is on. */
4
7
  enabled: z.boolean(),
8
+ /** Subscribed but blocked by overdue invoices (unpaid past grace). */
9
+ overdue: z.boolean(),
10
+ /** Computed: subscribed && enabled — can the user enter the product. */
11
+ canAccess: z.boolean(),
5
12
  })
6
13
 
7
14
  export const PermissionsResponseSchema = z.object({
@@ -15,7 +15,7 @@ export const SuiteLinkSchema = z.object({
15
15
  export const OrgProductStatusSchema = z.enum(['active', 'trial', 'not_subscribed'])
16
16
 
17
17
  /**
18
- * Billing context surfaced when a paying customer cannot access a product —
18
+ * Billing context surfaced when a overdue customer cannot access a product —
19
19
  * lets the UI explain *why* access is blocked (e.g. an unpaid invoice).
20
20
  */
21
21
  export const ProductBlockedReasonSchema = z.object({
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import type { SuiteProduct } from 'nuxt-ui-layer/types'
3
+
4
+ const props = defineProps<{
5
+ product: SuiteProduct
6
+ collapsed?: boolean
7
+ }>()
8
+
9
+ const { enabledProducts, navigateToProduct } = useProductSwitcher()
10
+
11
+ const selectedProduct = shallowRef<SuiteProduct>(props.product)
12
+
13
+ watch(selectedProduct, (value) => {
14
+ if (value !== props.product)
15
+ navigateToProduct(value)
16
+ })
17
+ </script>
18
+
19
+ <template>
20
+ <SNavigationProducts
21
+ v-model="selectedProduct"
22
+ :products="enabledProducts"
23
+ :collapsed="collapsed"
24
+ />
25
+ </template>
@@ -1,10 +1,15 @@
1
1
  export function useApiClient() {
2
- const { BACKEND_BASE_URL, ENV_CSRF_COOKIE_NAME, PLATFORM_URL } = useRuntimeConfig().public
2
+ const { BACKEND_BASE_URL, CSRF_COOKIE_NAME, PLATFORM_URL, ENVIRONMENT } = useRuntimeConfig().public
3
+
4
+
5
+ const baseCsrfName = (CSRF_COOKIE_NAME as string | undefined) || 'smt_csrf'
6
+ const environment = (ENVIRONMENT as string | undefined) || 'local'
7
+ const csrfCookieName = environment === 'prod' ? baseCsrfName : `${baseCsrfName}_${environment}`
3
8
 
4
9
  return createApiClient({
5
10
  baseURL: BACKEND_BASE_URL as string,
6
11
  csrf: {
7
- cookieName: (ENV_CSRF_COOKIE_NAME as string) || 'smt_csrf',
12
+ cookieName: csrfCookieName
8
13
  },
9
14
  auth: {
10
15
  refreshBaseURL: BACKEND_BASE_URL as string,
@@ -16,7 +21,11 @@ export function useApiClient() {
16
21
  },
17
22
  },
18
23
  onRefreshFailed: () => {
19
- if (import.meta.client) {
24
+ if (!import.meta.client) return
25
+ // An accounting-token exchange is in flight (00.accounting-token
26
+ // middleware) — redirecting to login now would clobber it.
27
+ if (new URLSearchParams(window.location.search).has('accounting_token')) return
28
+ if (!isPublicAuthRoute(window.location.pathname)) {
20
29
  window.location.assign(`${PLATFORM_URL}/auth/login`)
21
30
  }
22
31
  },
@@ -0,0 +1,114 @@
1
+ import type { PermissionsResponse } from '@package/platform-shared'
2
+ import type { SuiteProduct } from 'nuxt-ui-layer/types';
3
+ import { computed } from 'vue'
4
+
5
+ type ProductKey = keyof PermissionsResponse['products']
6
+
7
+ /**
8
+ * Product access state derived from the subscribed/enabled/overdue matrix
9
+ * (overdue = subscribed but blocked by unpaid invoices):
10
+ *
11
+ * | subscribed | enabled | overdue | state | action |
12
+ * |------------|---------|---------|-----------------|----------------------|
13
+ * | true | true | false | accessible | enter product |
14
+ * | true | false | true | overdue | view unpaid invoices |
15
+ * | true | false | false | blocked | discovery |
16
+ * | false | true | false | not_subscribed | go to billing |
17
+ * | false | false | false | not_subscribed | discovery |
18
+ */
19
+ export type ProductAccessState = 'accessible' | 'overdue' | 'blocked' | 'not_subscribed'
20
+
21
+ function resolveAccessState(product: { subscribed: boolean; enabled: boolean; overdue: boolean; canAccess: boolean }): ProductAccessState {
22
+ if (product.canAccess) return 'accessible'
23
+ if (product.subscribed && product.overdue) return 'overdue'
24
+ if (product.subscribed && !product.enabled) return 'blocked'
25
+ if (!product.subscribed && product.enabled) return 'not_subscribed'
26
+ return 'not_subscribed'
27
+ }
28
+
29
+ const PRODUCT_KEY_TO_SUITE: Record<ProductKey, SuiteProduct> = {
30
+ pricing: 'pricing',
31
+ connect: 'connect',
32
+ chat: 'chat',
33
+ smartpms: 'pms',
34
+ }
35
+
36
+ export function useProductSwitcher() {
37
+ const { data: permissions, status } = usePermissionsQuery()
38
+ const config = useRuntimeConfig().public
39
+
40
+ const productUrls: Record<string, string> = {
41
+ config: config.PLATFORM_APP_URL,
42
+ pricing: config.PRICING_APP_URL,
43
+ connect: config.CONNECT_APP_URL,
44
+ chat: config.CHAT_APP_URL,
45
+ pms: config.PMS_APP_URL,
46
+ }
47
+
48
+ /** Access state for each suite product. */
49
+ const productStates = computed<Map<SuiteProduct, ProductAccessState>>(() => {
50
+ const map = new Map<SuiteProduct, ProductAccessState>()
51
+ if (!permissions.value) return map
52
+
53
+ for (const [key, bundle] of Object.entries(permissions.value.products)) {
54
+ const suite = PRODUCT_KEY_TO_SUITE[key as ProductKey]
55
+ if (suite) {
56
+ map.set(suite, resolveAccessState(bundle))
57
+ }
58
+ }
59
+ return map
60
+ })
61
+
62
+ /** Products the user can fully enter (subscribed + enabled). */
63
+ const enabledProducts = computed<SuiteProduct[]>(() => {
64
+ const enabled: SuiteProduct[] = ['config' as SuiteProduct]
65
+
66
+ for (const [suite, state] of productStates.value) {
67
+ if (state === 'accessible') {
68
+ enabled.push(suite)
69
+ }
70
+ }
71
+
72
+ return enabled
73
+ })
74
+
75
+ const isLoading = computed(() => status.value === 'pending')
76
+
77
+ function navigateToProduct(product?: SuiteProduct) {
78
+ if (!product) return
79
+
80
+ const state = productStates.value.get(product) ?? 'not_subscribed'
81
+
82
+ switch (state) {
83
+ case 'accessible': {
84
+ const url = productUrls[product]
85
+ if (url) location.assign(url)
86
+ break
87
+ }
88
+ case 'overdue':
89
+ window.open(`${productUrls.config}/account/overview`, '_blank', 'noopener,noreferrer')
90
+ break
91
+ case 'blocked':
92
+ case 'not_subscribed':
93
+ window.open(`${productUrls.config}/upgrade`, '_blank', 'noopener,noreferrer')
94
+ break
95
+ }
96
+ }
97
+
98
+ function getProductUrl(product: SuiteProduct): string | undefined {
99
+ return productUrls[product] || undefined
100
+ }
101
+
102
+ function getProductState(product: SuiteProduct): ProductAccessState {
103
+ return productStates.value.get(product) ?? 'not_subscribed'
104
+ }
105
+
106
+ return {
107
+ enabledProducts,
108
+ productStates,
109
+ isLoading,
110
+ navigateToProduct,
111
+ getProductUrl,
112
+ getProductState,
113
+ }
114
+ }
@@ -18,5 +18,6 @@ export function useBillingDocumentPdfQuery(
18
18
  `/api/orgs/${encodeURIComponent(toValue(orgId))}/billing/documents/${toValue(type)}/${encodeURIComponent(toValue(id))}/pdf`,
19
19
  ),
20
20
  enabled: () => !!toValue(orgId) && !!toValue(id),
21
+ staleTime: 0,
21
22
  })
22
23
  }
@@ -31,5 +31,6 @@ export function useBillingDocumentsQuery(
31
31
  { query: toValue(params) },
32
32
  ),
33
33
  enabled: () => !!toValue(orgId),
34
+ staleTime: 0,
34
35
  })
35
36
  }
@@ -13,5 +13,6 @@ export function useBillingOverviewQuery(orgId: MaybeRefOrGetter<string>) {
13
13
  `/api/orgs/${encodeURIComponent(toValue(orgId))}/billing`,
14
14
  ),
15
15
  enabled: () => !!toValue(orgId),
16
+ staleTime: 0,
16
17
  })
17
18
  }
@@ -10,5 +10,6 @@ export function useCrossSellingQuery() {
10
10
  return useQuery({
11
11
  key: crossSellingKeys.all,
12
12
  query: () => client<GetCrossSellingResponse>('/api/me/cross-selling'),
13
+ staleTime: 0,
13
14
  })
14
15
  }
@@ -16,5 +16,6 @@ export function useLegalEntityQuery(
16
16
  `/api/orgs/${encodeURIComponent(toValue(orgId))}/billing/legal-entities/${encodeURIComponent(toValue(id))}`,
17
17
  ),
18
18
  enabled: () => !!toValue(orgId) && !!toValue(id),
19
+ staleTime: 0,
19
20
  })
20
21
  }
@@ -13,5 +13,6 @@ export function useOrganizationQuery(orgId: MaybeRefOrGetter<string>) {
13
13
  `/api/orgs/${encodeURIComponent(toValue(orgId))}`,
14
14
  ),
15
15
  enabled: () => !!toValue(orgId),
16
+ staleTime: 0,
16
17
  })
17
18
  }
@@ -10,5 +10,6 @@ export function usePermissionsQuery() {
10
10
  return useQuery({
11
11
  key: permissionsKeys.all,
12
12
  query: () => client<PermissionsResponse>('/api/me/permissions'),
13
+ staleTime: 0,
13
14
  })
14
15
  }
@@ -10,5 +10,6 @@ export function useSessionQuery() {
10
10
  return useQuery({
11
11
  key: sessionKeys.me,
12
12
  query: () => client<MeContextResponse>('/api/me'),
13
+ staleTime: 0,
13
14
  })
14
15
  }
@@ -32,12 +32,26 @@ export interface ApiClientOptions {
32
32
  onResponseError?: OnResponseErrorHook
33
33
  }
34
34
 
35
+ function errorCode(data: unknown): string | undefined {
36
+ return (data as { error?: { code?: string } } | undefined)?.error?.code
37
+ }
38
+
35
39
  function defaultShouldRefresh(status: number, data: unknown): boolean {
36
40
  if (status !== 401) return false
37
- const code = (data as { error?: { code?: string } } | undefined)?.error?.code
41
+ const code = errorCode(data)
38
42
  return code === 'session.expired' || code === 'session.missing'
39
43
  }
40
44
 
45
+ /**
46
+ * Codes that mean the session is dead and can't be recovered by a refresh: a tampered/corrupted or
47
+ * rotated-secret token (`session.invalid`), or `session.expired`/`session.missing` once a refresh
48
+ * has already been tried and failed. Distinct from non-session 401s (bad credentials, wrong OTP)
49
+ * which must NOT trigger a logout redirect.
50
+ */
51
+ function isDeadSessionCode(code: string | undefined): boolean {
52
+ return code === 'session.invalid' || code === 'session.expired' || code === 'session.missing'
53
+ }
54
+
41
55
  // Module-level refresh dedup — safe because refresh is client-only
42
56
  let refreshPromise: Promise<boolean> | null = null
43
57
 
@@ -136,6 +150,17 @@ export function createApiClient(options: ApiClientOptions) {
136
150
  serializeAndThrow(retryError)
137
151
  }
138
152
  }
153
+ }
154
+
155
+ // A dead-session 401 we reach here is unrecoverable: either it wasn't refresh-eligible — a
156
+ // tampered or rotated-secret token surfaces as `session.invalid`, which never becomes
157
+ // `session.expired` and so never self-heals — or the refresh above failed. Force re-auth
158
+ // instead of leaving the client "logged in" while every request 401s (the zombie state: a JWT
159
+ // secret rotation would drop all active users into it at once). onRefreshFailed does a hard
160
+ // redirect to login, which reloads the app and discards the stale in-memory auth state; it
161
+ // self-guards against the accounting-token exchange and public auth routes. Non-session 401s
162
+ // (bad credentials, wrong OTP) fall through untouched so callers can surface the error.
163
+ if (status === 401 && isDeadSessionCode(errorCode(data))) {
139
164
  auth.onRefreshFailed?.()
140
165
  }
141
166
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Auth paths that require an active session despite living under `/auth/`.
3
+ * Every other `/auth/*` route is public (login, activate, password reset, etc.).
4
+ */
5
+ const AUTHENTICATED_AUTH_PATHS = ['/auth/otp-step-up', '/auth/products'] as const
6
+
7
+ /**
8
+ * Returns `true` for `/auth/*` routes that are reachable without a session
9
+ * (e.g. login, activate, forgot-password, reset-password).
10
+ *
11
+ * Used to:
12
+ * - skip the boot-time session restore (avoids a guaranteed-to-fail GET /api/me)
13
+ * - suppress the redirect-to-login on refresh failure
14
+ */
15
+ export function isPublicAuthRoute(pathname: string): boolean {
16
+ if (!pathname.startsWith('/auth/')) return false
17
+ return !AUTHENTICATED_AUTH_PATHS.some(p => pathname.startsWith(p))
18
+ }
package/nuxt.config.ts CHANGED
@@ -46,8 +46,15 @@ export default defineNuxtConfig({
46
46
  runtimeConfig: {
47
47
  public: {
48
48
  BACKEND_BASE_URL: '',
49
- ENV_CSRF_COOKIE_NAME: '',
49
+ CSRF_COOKIE_NAME: '',
50
50
  PLATFORM_URL: '',
51
+ ENVIRONMENT: '',
52
+ // Standard product URL convention: {PRODUCT}_APP_URL
53
+ PLATFORM_APP_URL: '',
54
+ PRICING_APP_URL: '',
55
+ CONNECT_APP_URL: '',
56
+ CHAT_APP_URL: '',
57
+ PMS_APP_URL: '',
51
58
  },
52
59
  },
53
60
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.smartpricing/platform-layer",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./nuxt.config.ts",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "nuxt": "^4.4.2",
22
- "nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.6.3",
22
+ "nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.7.1-platform.1",
23
23
  "vue": "latest"
24
24
  },
25
25
  "peerDependencies": {
@@ -29,6 +29,6 @@
29
29
  "dev": "nuxi dev .playground",
30
30
  "dev:prepare": "nuxt prepare .playground",
31
31
  "build": "nuxt build .playground",
32
- "postinstall": "nuxt prepare"
32
+ "postinstall": "nuxt prepare .playground"
33
33
  }
34
34
  }