@dev.smartpricing/platform-layer 0.0.5 → 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.
@@ -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({
@@ -21,7 +21,11 @@ export function useApiClient() {
21
21
  },
22
22
  },
23
23
  onRefreshFailed: () => {
24
- if (import.meta.client && !window.location.pathname.startsWith('/auth/login')) {
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)) {
25
29
  window.location.assign(`${PLATFORM_URL}/auth/login`)
26
30
  }
27
31
  },
@@ -1,6 +1,38 @@
1
- import type { SuiteProduct } from 'nuxt-ui-layer/types'
1
+ import type { PermissionsResponse } from '@package/platform-shared'
2
+ import type { SuiteProduct } from 'nuxt-ui-layer/types';
2
3
  import { computed } from 'vue'
3
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
+
4
36
  export function useProductSwitcher() {
5
37
  const { data: permissions, status } = usePermissionsQuery()
6
38
  const config = useRuntimeConfig().public
@@ -13,28 +45,27 @@ export function useProductSwitcher() {
13
45
  pms: config.PMS_APP_URL,
14
46
  }
15
47
 
16
- /** All enabled product keys (from /permission + always-on "config"). */
17
- const enabledProducts = computed<SuiteProduct[]>(() => {
18
- // "config" is a new SuiteProduct value added in smartness-nuxt-ui — cast
19
- // needed until the updated nuxt-ui-layer package is published.
20
- const enabled: SuiteProduct[] = ['config' as SuiteProduct]
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
21
52
 
22
- if (!permissions.value)
23
- return enabled
24
-
25
- const products = permissions.value.products
26
- const mapping: Record<string, SuiteProduct> = {
27
- pricing: 'pricing',
28
- connect: 'connect',
29
- chat: 'chat',
30
- smartpms: 'pms',
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
+ }
31
58
  }
59
+ return map
60
+ })
32
61
 
33
- for (const [key, product] of Object.entries(products)) {
34
- if (product.enabled) {
35
- const mapped = mapping[key]
36
- if (mapped)
37
- enabled.push(mapped)
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)
38
69
  }
39
70
  }
40
71
 
@@ -46,25 +77,38 @@ export function useProductSwitcher() {
46
77
  function navigateToProduct(product?: SuiteProduct) {
47
78
  if (!product) return
48
79
 
49
- const isEnabled = enabledProducts.value.includes(product)
50
- if (!isEnabled) {
51
- window.open(`${window.location.origin}/upgrade`, '_blank', 'noopener,noreferrer')
52
- return
53
- }
80
+ const state = productStates.value.get(product) ?? 'not_subscribed'
54
81
 
55
- const url = productUrls[product]
56
- if (url)
57
- location.assign(url)
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
+ }
58
96
  }
59
97
 
60
98
  function getProductUrl(product: SuiteProduct): string | undefined {
61
99
  return productUrls[product] || undefined
62
100
  }
63
101
 
102
+ function getProductState(product: SuiteProduct): ProductAccessState {
103
+ return productStates.value.get(product) ?? 'not_subscribed'
104
+ }
105
+
64
106
  return {
65
107
  enabledProducts,
108
+ productStates,
66
109
  isLoading,
67
110
  navigateToProduct,
68
111
  getProductUrl,
112
+ getProductState,
69
113
  }
70
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.smartpricing/platform-layer",
3
- "version": "0.0.5",
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.19-platform.0",
22
+ "nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.7.1-platform.1",
23
23
  "vue": "latest"
24
24
  },
25
25
  "peerDependencies": {