* feat: add bike benefit handling and optional vacation accrual - Introduced bike benefit (cykelförmån) with calculations for annual market value and monthly taxable value. - Updated schemas to include new benefit types and validation rules. - Implemented API routes for creating, updating, and deleting employee benefits. - Enhanced salary calculation logic to accommodate new vacation rule options, including a 'none' option for no accrual. - Added UI components for managing employee benefits, including input for bike benefit specifics. - Created database migrations for employee benefits and updated salary line items to support new benefit types. * chore: remove Langfuse env var checks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: enhance OAuth callback URL handling and update default scopes for Visma integration * feat: remove trade_name field and simplify company naming in invoices * refactor: destructure canWrite from useCanWrite for consistency across components * feat: enhance PATCH endpoint to validate existing benefits and handle bike benefit updates * feat: add missing label for bike benefit in salary line item types --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
3.3 KiB
TypeScript
125 lines
3.3 KiB
TypeScript
import { VISMA_AUTH_URL, VISMA_TOKEN_URL, VISMA_REVOKE_URL } from './config';
|
|
import type { OAuthConfig, TokenResponse } from '../types';
|
|
import {
|
|
fetchWithTimeout,
|
|
OAUTH_TIMEOUT_MS,
|
|
OAUTH_REVOKE_TIMEOUT_MS,
|
|
} from '@/lib/http/fetch-with-timeout';
|
|
|
|
const DEFAULT_SCOPES = [
|
|
'offline_access',
|
|
'ea:api',
|
|
'ea:sales',
|
|
'ea:accounting',
|
|
'ea:purchase',
|
|
'vls:api',
|
|
];
|
|
|
|
const EACCOUNTING_ACR_VALUE = 'service:44643EB1-3F76-4C1C-A672-402AE8085934';
|
|
|
|
export function buildVismaAuthUrl(
|
|
config: OAuthConfig,
|
|
options?: { scopes?: string[]; state?: string; acrValues?: string },
|
|
): string {
|
|
const params = new URLSearchParams({
|
|
client_id: config.clientId,
|
|
redirect_uri: config.redirectUri,
|
|
response_type: 'code',
|
|
acr_values: options?.acrValues ?? EACCOUNTING_ACR_VALUE,
|
|
});
|
|
|
|
const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES;
|
|
params.set('scope', scopes.join(' '));
|
|
|
|
if (options?.state) {
|
|
params.set('state', options.state);
|
|
}
|
|
|
|
return `${VISMA_AUTH_URL}?${params.toString()}`;
|
|
}
|
|
|
|
function basicAuthHeader(config: OAuthConfig): string {
|
|
const encoded = btoa(`${config.clientId}:${config.clientSecret}`);
|
|
return `Basic ${encoded}`;
|
|
}
|
|
|
|
export async function exchangeVismaCode(
|
|
config: OAuthConfig,
|
|
code: string,
|
|
): Promise<TokenResponse> {
|
|
const response = await fetchWithTimeout(
|
|
VISMA_TOKEN_URL,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Authorization: basicAuthHeader(config),
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: config.redirectUri,
|
|
}).toString(),
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Visma token exchange' },
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`Visma token exchange failed: ${response.status} ${body}`);
|
|
}
|
|
|
|
return response.json() as Promise<TokenResponse>;
|
|
}
|
|
|
|
export async function refreshVismaToken(
|
|
config: OAuthConfig,
|
|
refreshToken: string,
|
|
): Promise<TokenResponse> {
|
|
const response = await fetchWithTimeout(
|
|
VISMA_TOKEN_URL,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Authorization: basicAuthHeader(config),
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: refreshToken,
|
|
}).toString(),
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: 'Visma token refresh' },
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`Visma token refresh failed: ${response.status} ${body}`);
|
|
}
|
|
|
|
return response.json() as Promise<TokenResponse>;
|
|
}
|
|
|
|
export async function revokeVismaToken(
|
|
config: OAuthConfig,
|
|
refreshToken: string,
|
|
): Promise<boolean> {
|
|
const response = await fetchWithTimeout(
|
|
VISMA_REVOKE_URL,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Authorization: basicAuthHeader(config),
|
|
},
|
|
body: new URLSearchParams({
|
|
token: refreshToken,
|
|
token_type_hint: 'refresh_token',
|
|
}).toString(),
|
|
},
|
|
{ timeoutMs: OAUTH_REVOKE_TIMEOUT_MS, description: 'Visma token revoke' },
|
|
);
|
|
|
|
return response.ok;
|
|
}
|