@dev.smartpricing/platform-layer 0.0.5 → 0.0.7
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/_shared/billingGeo.ts +158 -0
- package/_shared/billingLegalEntities.ts +38 -5
- package/_shared/billingPaymentMethods.ts +7 -1
- package/_shared/index.ts +1 -0
- package/_shared/permissions.ts +7 -0
- package/_shared/products.ts +1 -1
- package/app/composables/apiClient.composable.ts +5 -1
- package/app/composables/useProductSwitcher.composable.ts +72 -28
- package/app/queries/useBillingDocumentPdf.query.ts +1 -0
- package/app/queries/useBillingDocuments.query.ts +1 -0
- package/app/queries/useBillingOverview.query.ts +1 -0
- package/app/queries/useCrossSelling.query.ts +1 -0
- package/app/queries/useLegalEntity.query.ts +1 -0
- package/app/queries/useOrganization.query.ts +1 -0
- package/app/queries/usePermissions.query.ts +1 -0
- package/app/queries/useSession.query.ts +1 -0
- package/app/utils/apiClient.utils.ts +26 -1
- package/app/utils/auth.utils.ts +44 -0
- package/nuxt.config.ts +4 -1
- package/package.json +2 -2
|
@@ -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:
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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({
|
|
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'
|
package/_shared/permissions.ts
CHANGED
|
@@ -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({
|
package/_shared/products.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
}
|
|
@@ -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
|
|
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,44 @@
|
|
|
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
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reads a cookie value by name from `document.cookie`. Returns `undefined` when
|
|
22
|
+
* the cookie is absent or `document` is unavailable (SSR/build). Mirrors the
|
|
23
|
+
* reader in `apps/frontend/app/composables/useRest.ts`.
|
|
24
|
+
*/
|
|
25
|
+
export function readCookie(name: string): string | undefined {
|
|
26
|
+
if (typeof document === 'undefined') return undefined
|
|
27
|
+
const prefix = `${name}=`
|
|
28
|
+
for (const part of document.cookie.split('; ')) {
|
|
29
|
+
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length))
|
|
30
|
+
}
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the environment-scoped CSRF cookie name: prod keeps the bare base
|
|
36
|
+
* name, every other environment gets a `_<env>` suffix so sessions from
|
|
37
|
+
* different environments don't collide on the shared `.smartness.com` domain.
|
|
38
|
+
*
|
|
39
|
+
* Must stay in sync with the backend's `envScopedCookieName`
|
|
40
|
+
* (`apps/backend/src/utils/env.ts`).
|
|
41
|
+
*/
|
|
42
|
+
export function envScopedCsrfCookieName(baseName: string, environment: string): string {
|
|
43
|
+
return environment === 'prod' ? baseName : `${baseName}_${environment}`
|
|
44
|
+
}
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev.smartpricing/platform-layer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
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.
|
|
22
|
+
"nuxt-ui-layer": "github:smartpricing/smartness-nuxt-ui#v1.7.2",
|
|
23
23
|
"vue": "latest"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|