Files
accounted/lib/providers/visma/oauth.ts
T
MattssonandClaude Opus 4.7 3fa871c742 Bug/accounting suggestion (#456)
* 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>
2026-05-13 00:26:04 +02:00

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;
}