⏰ Deadline: Friday, 7 November 2025, 18:00 (local time)
💰 Bounty Decay: –10,000 sats per day after the deadline
Reward: ⚡️ 200,000 sats via Bitcoin Lightning Network upon merge of an accepted PR. Late submissions may still be merged, but bounty value reduces by 5,000 sats per calendar day starting Saturday, 8 November 2025. Applies to the first PR merged.
Build Business Central Time Tracking Extension for Azure DevOps
Overview
Create an Azure DevOps work item extension that enables users to track and manage time entries directly within work items, with seamless synchronization to Microsoft Dynamics 365 Business Central. This extension will be published to the Visual Studio Marketplace for public use.
Goals
- Enable time tracking within Azure DevOps work items
- Integrate with Business Central's timeRegistrationEntry API
- Provide manual time entry and history viewing
- Authenticate users via OAuth 2.0 (Microsoft Entra ID)
- Publish as a public marketplace extension
Reference Implementations
Features to Implement
Core Features
-
Manual Time Entry
- Add time entries with date, duration, and description
- Associate time entries with current work item
- Link to Business Central job numbers (optional)
- Validate required fields before submission
-
View Time Entry History
- Display all time entries for the current work item
- Show total time tracked per work item
- Filter by date range
- Display sync status (synced to BC, pending, error)
-
Business Central Integration
- Create time entries in Business Central via API
- Map Azure DevOps work item IDs to BC job numbers
- Handle authentication and token refresh
- Error handling and retry logic
Technical Architecture
Extension Type
Work Item Form Extension - Embedded UI within Azure DevOps work item forms (tasks, bugs, user stories, etc.)
Technology Stack
- Frontend: HTML5, CSS3, JavaScript (vanilla or framework of choice)
- Azure DevOps SDK: VSS.SDK.min.js
- Authentication: OAuth 2.0 via Microsoft Entra ID
- API: Business Central REST API v2.0
- Packaging: TFX CLI for VSIX creation
Architecture Diagram
┌─────────────────────────────────────┐
│ Azure DevOps Work Item Form │
│ ┌──────────────────────────────┐ │
│ │ Time Tracking Extension │ │
│ │ - Manual Entry Form │ │
│ │ - Time Entry List │ │
│ │ - BC Authentication │ │
│ └──────────────────────────────┘ │
└─────────────┬───────────────────────┘
│
│ OAuth 2.0 + REST API
│
▼
┌─────────────────────────────────────┐
│ Microsoft Dynamics 365 │
│ Business Central API v2.0 │
│ - timeRegistrationEntry endpoint │
│ - Employee/Job data │
└─────────────────────────────────────┘
Prerequisites & Development Setup
Required Tools
- Node.js and npm (latest LTS version)
- TFX CLI: Install globally
- Visual Studio Code (recommended IDE)
- Azure DevOps Organization (for testing)
- Business Central Environment (for API testing)
Azure/Microsoft Setup
- Publisher Account: Create a publisher on Visual Studio Marketplace Publishing Portal
- Microsoft Entra ID App Registration:
- Register app in Azure Portal
- Add API permission: Dynamics 365 Business Central > Financials.ReadWrite.All (Delegated)
- Create client secret (store securely)
- Configure redirect URI for OAuth flow
- Business Central Access: Ensure test environment access
Documentation Resources
Implementation Steps
Step 1: Project Structure Setup
Create the following directory structure:
devops-bc-timetracker/
├── vss-extension.json # Extension manifest
├── time-tracker.html # Main extension UI
├── time-tracker.css # Styling
├── time-tracker.js # Extension logic
├── bc-api.js # Business Central API integration
├── auth.js # OAuth authentication logic
├── sdk/
│ └── scripts/
│ └── VSS.SDK.min.js # Download from Microsoft
├── img/
│ ├── logo.png # 98x98 extension logo
│ ├── icon.png # 98x98 catalog icon
│ └── preview.png # 330x160 preview image
├── README.md # User documentation
├── PRIVACY.md # Privacy policy (required)
├── TERMS.md # Terms of use (required)
└── package.json # npm configuration
Step 2: Extension Manifest (vss-extension.json)
Create the manifest file with extension metadata and contribution points:
{
"manifestVersion": 1,
"id": "bc-timetracker",
"name": "Business Central Time Tracker",
"version": "1.0.0",
"publisher": "YOUR-PUBLISHER-ID",
"description": "Track time on work items and sync to Microsoft Dynamics 365 Business Central",
"public": true,
"categories": ["Azure Boards"],
"targets": [
{
"id": "Microsoft.VisualStudio.Services"
}
],
"icons": {
"default": "img/logo.png"
},
"content": {
"details": {
"path": "README.md"
},
"license": {
"path": "TERMS.md"
}
},
"links": {
"support": {
"uri": "https://github.com/YOUR-ORG/devops-bc-timetracker/issues"
},
"privacypolicy": {
"uri": "https://YOUR-DOMAIN/privacy"
}
},
"scopes": [
"vso.work",
"vso.work_write"
],
"contributions": [
{
"id": "bc-timetracker-work-item-form",
"type": "ms.vss-work-web.work-item-form-group",
"description": "Track time and sync to Business Central",
"targets": [
"ms.vss-work-web.work-item-form"
],
"properties": {
"name": "Time Tracking",
"uri": "time-tracker.html",
"height": 400
}
}
],
"files": [
{
"path": "time-tracker.html",
"addressable": true
},
{
"path": "time-tracker.css",
"addressable": true
},
{
"path": "time-tracker.js",
"addressable": true
},
{
"path": "bc-api.js",
"addressable": true
},
{
"path": "auth.js",
"addressable": true
},
{
"path": "sdk/scripts",
"addressable": true
},
{
"path": "img",
"addressable": true
}
]
}
Key Configuration Notes:
- Replace
YOUR-PUBLISHER-ID with your marketplace publisher ID
vso.work_write scope enables reading and writing work items
- Work item form group appears as a tab/section in work item forms
- Adjust height as needed for your UI
Step 3: Extension UI (time-tracker.html)
Create the user interface for time entry and history display:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>BC Time Tracker</title>
<link rel="stylesheet" href="time-tracker.css" />
<script src="sdk/scripts/VSS.SDK.min.js"></script>
</head>
<body>
<div id="time-tracker-container">
<!-- Authentication Section -->
<div id="auth-section">
<button id="auth-button" class="primary-button">
Connect to Business Central
</button>
<div id="auth-status"></div>
</div>
<!-- Time Entry Form -->
<div id="entry-form" class="hidden">
<h3>Log Time Entry</h3>
<form id="time-entry-form">
<div class="form-group">
<label for="entry-date">Date:</label>
<input type="date" id="entry-date" required />
</div>
<div class="form-group">
<label for="entry-hours">Hours:</label>
<input type="number" id="entry-hours" step="0.25" min="0.25" required />
</div>
<div class="form-group">
<label for="entry-description">Description:</label>
<textarea id="entry-description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="bc-job-number">BC Job Number (Optional):</label>
<input type="text" id="bc-job-number" placeholder="Leave blank for general time" />
</div>
<button type="submit" class="primary-button">Log Time</button>
</form>
</div>
<!-- Time Entry History -->
<div id="history-section" class="hidden">
<h3>Time Entry History</h3>
<div id="total-time">Total: <strong>0.00 hours</strong></div>
<div id="time-entries-list"></div>
</div>
<!-- Loading/Error States -->
<div id="loading-indicator" class="hidden">Loading...</div>
<div id="error-message" class="hidden error"></div>
</div>
<script src="auth.js"></script>
<script src="bc-api.js"></script>
<script src="time-tracker.js"></script>
</body>
</html>
Step 4: Extension Logic (time-tracker.js)
Main JavaScript file to handle Azure DevOps integration and UI logic:
// Initialize VSS SDK
VSS.init({
explicitNotifyLoaded: true,
usePlatformStyles: true
});
VSS.ready(function() {
// Get work item form service
VSS.require(["TFS/WorkItemTracking/Services"], function(WorkItemServices) {
WorkItemServices.WorkItemFormService.getService().then(function(workItemService) {
// Get current work item ID
workItemService.getId().then(function(workItemId) {
initializeExtension(workItemId, workItemService);
});
});
});
});
let currentWorkItemId = null;
let workItemService = null;
function initializeExtension(workItemId, service) {
currentWorkItemId = workItemId;
workItemService = service;
// Check authentication status
checkAuthStatus();
// Set up event listeners
document.getElementById('auth-button').addEventListener('click', authenticateWithBC);
document.getElementById('time-entry-form').addEventListener('submit', handleTimeEntrySubmit);
// Notify that extension is loaded
VSS.notifyLoadSucceeded();
}
function checkAuthStatus() {
const token = getStoredAuthToken();
if (token && !isTokenExpired(token)) {
showAuthenticatedUI();
loadTimeEntries();
} else {
showUnauthenticatedUI();
}
}
function showAuthenticatedUI() {
document.getElementById('auth-section').classList.add('hidden');
document.getElementById('entry-form').classList.remove('hidden');
document.getElementById('history-section').classList.remove('hidden');
}
function showUnauthenticatedUI() {
document.getElementById('auth-section').classList.remove('hidden');
document.getElementById('entry-form').classList.add('hidden');
document.getElementById('history-section').classList.add('hidden');
}
async function handleTimeEntrySubmit(event) {
event.preventDefault();
showLoading(true);
hideError();
try {
// Get form values
const date = document.getElementById('entry-date').value;
const hours = parseFloat(document.getElementById('entry-hours').value);
const description = document.getElementById('entry-description').value;
const jobNumber = document.getElementById('bc-job-number').value;
// Get work item details for context
const workItemTitle = await workItemService.getFieldValue('System.Title');
const workItemType = await workItemService.getFieldValue('System.WorkItemType');
// Create time entry in Business Central
const timeEntry = {
date: date,
quantity: hours,
jobNumber: jobNumber || '',
description: `${workItemType} #${currentWorkItemId}: ${workItemTitle}${description ? ' - ' + description : ''}`
};
const result = await createTimeEntryInBC(timeEntry);
// Store reference to work item
await storeTimeEntryReference(currentWorkItemId, result.id);
// Refresh history
await loadTimeEntries();
// Clear form
document.getElementById('time-entry-form').reset();
document.getElementById('entry-date').valueAsDate = new Date();
showSuccess('Time entry logged successfully!');
} catch (error) {
showError('Failed to log time entry: ' + error.message);
} finally {
showLoading(false);
}
}
async function loadTimeEntries() {
try {
const entries = await getTimeEntriesForWorkItem(currentWorkItemId);
displayTimeEntries(entries);
} catch (error) {
console.error('Failed to load time entries:', error);
showError('Failed to load time entry history');
}
}
function displayTimeEntries(entries) {
const listElement = document.getElementById('time-entries-list');
const totalElement = document.getElementById('total-time');
if (!entries || entries.length === 0) {
listElement.innerHTML = '<p class="no-entries">No time entries yet</p>';
totalElement.innerHTML = 'Total: <strong>0.00 hours</strong>';
return;
}
// Calculate total
const total = entries.reduce((sum, entry) => sum + (entry.quantity || 0), 0);
totalElement.innerHTML = `Total: <strong>${total.toFixed(2)} hours</strong>`;
// Display entries
listElement.innerHTML = entries.map(entry => `
<div class="time-entry">
<div class="entry-date">${formatDate(entry.date)}</div>
<div class="entry-hours">${entry.quantity.toFixed(2)}h</div>
<div class="entry-description">${entry.description || 'No description'}</div>
${entry.jobNumber ? `<div class="entry-job">Job: ${entry.jobNumber}</div>` : ''}
</div>
`).join('');
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function showLoading(show) {
document.getElementById('loading-indicator').classList.toggle('hidden', !show);
}
function showError(message) {
const errorElement = document.getElementById('error-message');
errorElement.textContent = message;
errorElement.classList.remove('hidden');
setTimeout(() => errorElement.classList.add('hidden'), 5000);
}
function hideError() {
document.getElementById('error-message').classList.add('hidden');
}
function showSuccess(message) {
// Implement success notification (could use Azure DevOps notification service)
console.log('Success:', message);
}
Step 5: Business Central API Integration (bc-api.js)
Handle all Business Central API calls:
const BC_API_BASE = 'https://api.businesscentral.dynamics.com/v2.0';
const BC_API_VERSION = 'v2.0';
// Storage keys
const STORAGE_KEY_ENTRIES = 'bc_time_entries';
const STORAGE_KEY_CONFIG = 'bc_config';
/**
* Create a time entry in Business Central
*/
async function createTimeEntryInBC(timeEntry) {
const config = getBCConfig();
const token = getStoredAuthToken();
if (!token) {
throw new Error('Not authenticated with Business Central');
}
// Construct API endpoint
const endpoint = `${BC_API_BASE}/${config.tenantId}/${config.environment}/api/${BC_API_VERSION}/companies(${config.companyId})/timeRegistrationEntries`;
// Prepare request body
const body = {
date: timeEntry.date,
quantity: timeEntry.quantity,
employeeId: config.employeeId,
jobNumber: timeEntry.jobNumber || '',
jobTaskNumber: '',
unitOfMeasureCode: 'HOUR'
};
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'Failed to create time entry');
}
const result = await response.json();
return result;
} catch (error) {
console.error('BC API Error:', error);
// Check if token expired
if (error.message && error.message.includes('401')) {
await refreshAuthToken();
// Retry once
return createTimeEntryInBC(timeEntry);
}
throw error;
}
}
/**
* Get time entries from Business Central for a specific work item
*/
async function getTimeEntriesForWorkItem(workItemId) {
// In a real implementation, you would:
// 1. Query BC API with a filter for entries related to this work item
// 2. Store work item ID in a custom field or in the description
// 3. Parse and return matching entries
const config = getBCConfig();
const token = getStoredAuthToken();
if (!token) {
return [];
}
const endpoint = `${BC_API_BASE}/${config.tenantId}/${config.environment}/api/${BC_API_VERSION}/companies(${config.companyId})/timeRegistrationEntries`;
try {
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch time entries');
}
const data = await response.json();
// Filter entries that reference this work item
// This assumes work item ID is in the description or a custom field
const entries = data.value.filter(entry =>
entry.description && entry.description.includes(`#${workItemId}`)
);
return entries;
} catch (error) {
console.error('Failed to get time entries:', error);
return [];
}
}
/**
* Get Business Central configuration
*/
function getBCConfig() {
const stored = localStorage.getItem(STORAGE_KEY_CONFIG);
if (stored) {
return JSON.parse(stored);
}
// Default/placeholder - in production, get from user settings
return {
tenantId: 'YOUR_TENANT_ID',
environment: 'production',
companyId: 'YOUR_COMPANY_ID',
employeeId: 'USER_EMPLOYEE_ID'
};
}
/**
* Save Business Central configuration
*/
function saveBCConfig(config) {
localStorage.setItem(STORAGE_KEY_CONFIG, JSON.stringify(config));
}
/**
* Store reference between work item and BC time entry
*/
async function storeTimeEntryReference(workItemId, bcEntryId) {
const references = JSON.parse(localStorage.getItem(STORAGE_KEY_ENTRIES) || '{}');
if (!references[workItemId]) {
references[workItemId] = [];
}
references[workItemId].push({
bcEntryId: bcEntryId,
timestamp: new Date().toISOString()
});
localStorage.setItem(STORAGE_KEY_ENTRIES, JSON.stringify(references));
}
Step 6: Authentication (auth.js)
OAuth 2.0 authentication flow for Business Central:
const AUTH_CONFIG = {
clientId: 'YOUR_CLIENT_ID', // From Azure App Registration
authority: 'https://login.microsoftonline.com/common',
redirectUri: window.location.origin, // Or specific redirect URI
scopes: ['https://api.businesscentral.dynamics.com/Financials.ReadWrite.All']
};
const STORAGE_KEY_TOKEN = 'bc_auth_token';
const STORAGE_KEY_REFRESH = 'bc_refresh_token';
/**
* Initiate OAuth authentication flow
*/
async function authenticateWithBC() {
try {
// Build authorization URL
const authUrl = buildAuthUrl();
// Open popup for authentication
const popup = window.open(authUrl, 'BC Authentication', 'width=500,height=600');
// Wait for callback
const token = await waitForAuthCallback(popup);
// Store token
storeAuthToken(token);
// Update UI
showAuthenticatedUI();
loadTimeEntries();
} catch (error) {
console.error('Authentication failed:', error);
showError('Failed to authenticate with Business Central: ' + error.message);
}
}
function buildAuthUrl() {
const params = new URLSearchParams({
client_id: AUTH_CONFIG.clientId,
response_type: 'code',
redirect_uri: AUTH_CONFIG.redirectUri,
scope: AUTH_CONFIG.scopes.join(' '),
response_mode: 'query'
});
return `${AUTH_CONFIG.authority}/oauth2/v2.0/authorize?${params.toString()}`;
}
function waitForAuthCallback(popup) {
return new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
try {
if (popup.closed) {
clearInterval(checkInterval);
reject(new Error('Authentication cancelled'));
return;
}
// Check if popup URL contains code
const popupUrl = popup.location.href;
if (popupUrl.includes('code=')) {
clearInterval(checkInterval);
const code = new URL(popupUrl).searchParams.get('code');
popup.close();
// Exchange code for token
exchangeCodeForToken(code).then(resolve).catch(reject);
}
} catch (e) {
// Cross-origin error means we're still on Microsoft's domain
}
}, 500);
});
}
async function exchangeCodeForToken(code) {
// In production, this should be done server-side for security
// This is a simplified example
const tokenEndpoint = `${AUTH_CONFIG.authority}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: AUTH_CONFIG.clientId,
scope: AUTH_CONFIG.scopes.join(' '),
code: code,
redirect_uri: AUTH_CONFIG.redirectUri,
grant_type: 'authorization_code',
client_secret: 'YOUR_CLIENT_SECRET' // NEVER expose in production!
});
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!response.ok) {
throw new Error('Failed to exchange authorization code');
}
const tokenData = await response.json();
return tokenData;
}
function storeAuthToken(tokenData) {
const tokenInfo = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt: Date.now() + (tokenData.expires_in * 1000),
tokenType: tokenData.token_type
};
localStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(tokenInfo));
}
function getStoredAuthToken() {
const stored = localStorage.getItem(STORAGE_KEY_TOKEN);
if (!stored) return null;
const tokenInfo = JSON.parse(stored);
return tokenInfo.accessToken;
}
function isTokenExpired(token) {
const stored = localStorage.getItem(STORAGE_KEY_TOKEN);
if (!stored) return true;
const tokenInfo = JSON.parse(stored);
return Date.now() >= tokenInfo.expiresAt;
}
async function refreshAuthToken() {
const stored = localStorage.getItem(STORAGE_KEY_TOKEN);
if (!stored) {
throw new Error('No refresh token available');
}
const tokenInfo = JSON.parse(stored);
const refreshToken = tokenInfo.refreshToken;
const tokenEndpoint = `${AUTH_CONFIG.authority}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: AUTH_CONFIG.clientId,
scope: AUTH_CONFIG.scopes.join(' '),
refresh_token: refreshToken,
grant_type: 'refresh_token',
client_secret: 'YOUR_CLIENT_SECRET'
});
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!response.ok) {
// Refresh failed, need to re-authenticate
localStorage.removeItem(STORAGE_KEY_TOKEN);
showUnauthenticatedUI();
throw new Error('Token refresh failed');
}
const tokenData = await response.json();
storeAuthToken(tokenData);
}
IMPORTANT SECURITY NOTE: The above code shows client-side token exchange for demonstration. In production, you should:
- Use a backend proxy server to handle OAuth flow
- Never expose client secrets in browser code
- Implement PKCE (Proof Key for Code Exchange) for public clients
- Store tokens securely (consider using Azure DevOps data service instead of localStorage)
Step 7: Styling (time-tracker.css)
Basic styling that follows Azure DevOps design patterns:
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
color: #333;
margin: 0;
padding: 16px;
}
#time-tracker-container {
max-width: 800px;
}
.hidden {
display: none !important;
}
/* Buttons */
.primary-button {
background-color: #0078d4;
color: white;
border: none;
padding: 8px 16px;
border-radius: 2px;
cursor: pointer;
font-size: 14px;
}
.primary-button:hover {
background-color: #005a9e;
}
.primary-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* Forms */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 2px;
font-size: 14px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #0078d4;
}
/* Time Entries */
#total-time {
margin: 16px 0;
font-size: 16px;
}
.time-entry {
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 12px;
margin-bottom: 8px;
background-color: #fafafa;
}
.entry-date {
font-weight: 600;
color: #0078d4;
}
.entry-hours {
display: inline-block;
background-color: #0078d4;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
margin: 4px 0;
}
.entry-description {
margin-top: 8px;
color: #666;
}
.entry-job {
margin-top: 4px;
font-size: 12px;
color: #888;
}
.no-entries {
color: #888;
font-style: italic;
}
/* States */
#loading-indicator {
text-align: center;
padding: 20px;
color: #0078d4;
}
.error {
background-color: #fde7e9;
border: 1px solid #e81123;
color: #a80000;
padding: 12px;
border-radius: 4px;
margin: 16px 0;
}
#auth-section {
text-align: center;
padding: 32px;
}
#auth-status {
margin-top: 16px;
color: #666;
}
h3 {
margin-top: 0;
margin-bottom: 16px;
color: #333;
}
Business Central API Details
API Endpoint Structure
Base URL: https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/v2.0/
Time Registration Endpoints:
GET /companies({companyId})/timeRegistrationEntries - List all entries
POST /companies({companyId})/timeRegistrationEntries - Create new entry
GET /companies({companyId})/timeRegistrationEntries({id}) - Get specific entry
PATCH /companies({companyId})/timeRegistrationEntries({id}) - Update entry
DELETE /companies({companyId})/timeRegistrationEntries({id}) - Delete entry
Alternative employee-specific endpoint:
POST /companies({companyId})/employees({employeeId})/timeRegistrationEntries
timeRegistrationEntry Resource Properties
| Property |
Type |
Description |
Required |
id |
GUID |
Unique identifier (auto-generated) |
No |
employeeId |
GUID |
Employee's unique identifier |
Yes |
employeeNumber |
string |
Employee number (alternative to employeeId) |
No |
jobId |
GUID |
Related job identifier |
No |
jobNumber |
string |
Job number (for project tracking) |
No |
jobTaskNumber |
string |
Specific task within job |
No |
absence |
string |
Absence code if time off |
No |
lineNumber |
integer |
Line number in timesheet |
No |
date |
date |
Date of time entry |
Yes |
quantity |
decimal |
Hours worked |
Yes |
status |
enum |
Entry status (Open, Submitted, Approved) |
No |
unitOfMeasureId |
GUID |
Unit of measure identifier |
No |
unitOfMeasureCode |
string |
Unit code (e.g., "HOUR") |
No |
lastModifiedDateTime |
datetime |
Last modification timestamp |
No |
Example API Request
Create Time Entry:
POST https://api.businesscentral.dynamics.com/v2.0/{tenantId}/production/api/v2.0/companies({companyId})/timeRegistrationEntries
Authorization: Bearer {access_token}
Content-Type: application/json
{
"employeeId": "258bb9c0-44e3-ea11-bb43-000d3a2feca1",
"date": "2025-10-27",
"quantity": 3.5,
"jobNumber": "PROJ-001",
"jobTaskNumber": "TASK-100",
"unitOfMeasureCode": "HOUR"
}
Response:
{
"@odata.context": "https://api.businesscentral.dynamics.com/v2.0/{tenantId}/production/api/v2.0/$metadata#companies({companyId})/timeRegistrationEntries/$entity",
"id": "12345678-90ab-cdef-1234-567890abcdef",
"employeeId": "258bb9c0-44e3-ea11-bb43-000d3a2feca1",
"employeeNumber": "EMP001",
"date": "2025-10-27",
"quantity": 3.5,
"jobNumber": "PROJ-001",
"jobTaskNumber": "TASK-100",
"unitOfMeasureCode": "HOUR",
"status": "Open",
"lastModifiedDateTime": "2025-10-27T14:30:00Z"
}
Authentication Setup
1. Register App in Azure Portal
- Navigate to Azure Portal > Microsoft Entra ID > App Registrations
- Click "New registration"
- Name: "Azure DevOps BC Time Tracker"
- Supported account types: "Accounts in any organizational directory (Multitenant)"
- Redirect URI: Web -
https://YOUR-EXTENSION-URL/auth-callback
2. Configure API Permissions
- Go to "API permissions" > "Add a permission"
- Select "Dynamics 365 Business Central"
- Choose "Delegated permissions"
- Select "Financials.ReadWrite.All"
- Click "Add permissions"
- Admin consent may be required
3. Create Client Secret
- Go to "Certificates & secrets" > "New client secret"
- Description: "AzureDevOps Extension"
- Expiry: Choose appropriate duration
- Copy the secret value immediately (not retrievable later)
4. Note Required Values
- Application (client) ID
- Directory (tenant) ID
- Client secret value
Error Handling
Common API errors and handling strategies:
| Error Code |
Description |
Solution |
| 401 |
Unauthorized |
Refresh access token or re-authenticate |
| 403 |
Forbidden |
Check API permissions and user roles |
| 404 |
Not Found |
Verify companyId and endpoint URL |
| 429 |
Too Many Requests |
Implement retry with exponential backoff |
| 500 |
Server Error |
Log error and retry after delay |
Testing & Quality Assurance
Local Testing
-
Package Extension:
tfx extension create --manifest-globs vss-extension.json
-
Upload to Test Publisher:
- Use a separate test publisher account
- Upload VSIX file via Publishing Portal
- Share with your test organization
-
Install in Test Organization:
- Go to Organization Settings > Extensions
- Browse marketplace and search for your extension
- Install to your organization
-
Test Scenarios:
- Authentication flow (first-time and returning users)
- Create time entry with all fields
- Create time entry with minimal fields
- View time history
- Error handling (network failures, invalid data)
- Token expiration and refresh
- Multiple work item types
- Concurrent usage across different work items
Business Central Integration Testing
- Set up test Business Central environment
- Create test employee record
- Verify API permissions are granted
- Test time entry creation via Postman/Insomnia first
- Verify entries appear in BC timesheet interface
- Test job number associations
- Verify data accuracy (hours, dates, descriptions)
Browser Compatibility
Test in browsers commonly used with Azure DevOps:
- Microsoft Edge (latest)
- Google Chrome (latest)
- Mozilla Firefox (latest)
- Safari (latest, for Mac users)
Deployment & Publishing
Pre-Publishing Checklist
Marketplace Requirements
Required Documentation Files:
-
README.md - User guide with:
- Feature overview
- Installation instructions
- Configuration steps
- Usage examples
- Screenshots
- Support contact information
-
PRIVACY.md - Privacy policy covering:
- What data is collected
- How data is used
- Data storage locations
- User rights
- Contact information
-
TERMS.md - Terms of use covering:
- License agreement
- Usage restrictions
- Warranty disclaimer
- Liability limitations
Required Images:
- Extension logo: 98x98 pixels (PNG)
- Catalog icon: 98x98 pixels (PNG)
- Preview screenshot: 330x160 pixels minimum (PNG)
- Additional screenshots showing features
Publishing Steps
-
Create Publisher Account:
-
Package Extension:
tfx extension create --manifest-globs vss-extension.json
-
Publish to Marketplace:
tfx extension publish --manifest-globs vss-extension.json --share-with YOUR_ORG
Or upload manually via Publishing Portal
-
Complete Marketplace Listing:
- Add detailed description
- Upload screenshots
- Add support links
- Set categories and tags
- Define pricing
-
Request Public Visibility:
- Initially published as private/preview
- Request public listing after testing
- Microsoft reviews submission
- Approval typically takes 1-3 business days
Maintenance & Updates
Version Updates:
- Increment version in
vss-extension.json
- Document changes in changelog
- Test thoroughly before publishing
- Package and publish update
- Notify users of breaking changes
Important: You cannot change OAuth scopes after publishing. If scope changes are needed, you must unpublish, update, and republish the extension.
Security Considerations
Data Protection
- Store OAuth tokens securely (consider Azure DevOps data storage service)
- Never expose client secrets in client-side code
- Implement backend proxy for sensitive API calls
- Use HTTPS for all communications
- Validate all user inputs before API calls
Privacy
- Clearly document what data is sent to Business Central
- Obtain user consent before syncing data
- Allow users to disconnect/revoke access
- Don't store sensitive data in browser storage
- Comply with GDPR and data protection regulations
Authentication Best Practices
- Implement PKCE for OAuth flows
- Handle token refresh gracefully
- Provide clear error messages (without exposing sensitive details)
- Implement rate limiting on API calls
- Log authentication events for auditing
Future Enhancements
Consider adding these features in future versions:
-
Timer Functionality:
- Start/stop timer for real-time tracking
- Automatic time entry creation from timer
- Pause/resume capability
-
Dashboard Widget:
- Overview of all time entries across work items
- Weekly/monthly summaries
- Visual charts and graphs
-
Bulk Operations:
- Copy time entries from previous days
- Bulk edit or delete
- Export to CSV
-
Advanced Mapping:
- Auto-map work items to BC jobs
- Custom field mapping
- Team/project-level configurations
-
Offline Support:
- Queue time entries when offline
- Sync when connection restored
- Conflict resolution
-
Reporting:
- Generate time reports
- Filter by date range, project, user
- Export to PDF or Excel
-
Approvals Workflow:
- Submit time entries for approval
- Manager approval interface
- Approval status tracking
Reference Links
Azure DevOps Extension Development
Business Central API
Authentication & Security
Visual Studio Marketplace
Reference Implementations
Tools & SDKs
Community & Support
Support & Contributing
Getting Help
- Check documentation links above
- Review existing GitHub issues
- Create new issue for bugs or feature requests
- Contact: ben.weeks@outlook.com
Contributing
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Make your changes with tests
- Submit a pull request
- Follow coding standards and conventions
- Include a video of the widget working in DevOps (woritem)
⏰ Deadline: Friday, 7 November 2025, 18:00 (local time)
💰 Bounty Decay: –10,000 sats per day after the deadline
Reward: ⚡️ 200,000 sats via Bitcoin Lightning Network upon merge of an accepted PR. Late submissions may still be merged, but bounty value reduces by 5,000 sats per calendar day starting Saturday, 8 November 2025. Applies to the first PR merged.
Build Business Central Time Tracking Extension for Azure DevOps
Overview
Create an Azure DevOps work item extension that enables users to track and manage time entries directly within work items, with seamless synchronization to Microsoft Dynamics 365 Business Central. This extension will be published to the Visual Studio Marketplace for public use.
Goals
Reference Implementations
Features to Implement
Core Features
Manual Time Entry
View Time Entry History
Business Central Integration
Technical Architecture
Extension Type
Work Item Form Extension - Embedded UI within Azure DevOps work item forms (tasks, bugs, user stories, etc.)
Technology Stack
Architecture Diagram
Prerequisites & Development Setup
Required Tools
Azure/Microsoft Setup
Documentation Resources
Implementation Steps
Step 1: Project Structure Setup
Create the following directory structure:
Step 2: Extension Manifest (vss-extension.json)
Create the manifest file with extension metadata and contribution points:
{ "manifestVersion": 1, "id": "bc-timetracker", "name": "Business Central Time Tracker", "version": "1.0.0", "publisher": "YOUR-PUBLISHER-ID", "description": "Track time on work items and sync to Microsoft Dynamics 365 Business Central", "public": true, "categories": ["Azure Boards"], "targets": [ { "id": "Microsoft.VisualStudio.Services" } ], "icons": { "default": "img/logo.png" }, "content": { "details": { "path": "README.md" }, "license": { "path": "TERMS.md" } }, "links": { "support": { "uri": "https://github.com/YOUR-ORG/devops-bc-timetracker/issues" }, "privacypolicy": { "uri": "https://YOUR-DOMAIN/privacy" } }, "scopes": [ "vso.work", "vso.work_write" ], "contributions": [ { "id": "bc-timetracker-work-item-form", "type": "ms.vss-work-web.work-item-form-group", "description": "Track time and sync to Business Central", "targets": [ "ms.vss-work-web.work-item-form" ], "properties": { "name": "Time Tracking", "uri": "time-tracker.html", "height": 400 } } ], "files": [ { "path": "time-tracker.html", "addressable": true }, { "path": "time-tracker.css", "addressable": true }, { "path": "time-tracker.js", "addressable": true }, { "path": "bc-api.js", "addressable": true }, { "path": "auth.js", "addressable": true }, { "path": "sdk/scripts", "addressable": true }, { "path": "img", "addressable": true } ] }Key Configuration Notes:
YOUR-PUBLISHER-IDwith your marketplace publisher IDvso.work_writescope enables reading and writing work itemsStep 3: Extension UI (time-tracker.html)
Create the user interface for time entry and history display:
Step 4: Extension Logic (time-tracker.js)
Main JavaScript file to handle Azure DevOps integration and UI logic:
Step 5: Business Central API Integration (bc-api.js)
Handle all Business Central API calls:
Step 6: Authentication (auth.js)
OAuth 2.0 authentication flow for Business Central:
IMPORTANT SECURITY NOTE: The above code shows client-side token exchange for demonstration. In production, you should:
Step 7: Styling (time-tracker.css)
Basic styling that follows Azure DevOps design patterns:
Business Central API Details
API Endpoint Structure
Base URL:
https://api.businesscentral.dynamics.com/v2.0/{tenantId}/{environment}/api/v2.0/Time Registration Endpoints:
GET /companies({companyId})/timeRegistrationEntries- List all entriesPOST /companies({companyId})/timeRegistrationEntries- Create new entryGET /companies({companyId})/timeRegistrationEntries({id})- Get specific entryPATCH /companies({companyId})/timeRegistrationEntries({id})- Update entryDELETE /companies({companyId})/timeRegistrationEntries({id})- Delete entryAlternative employee-specific endpoint:
POST /companies({companyId})/employees({employeeId})/timeRegistrationEntriestimeRegistrationEntry Resource Properties
idemployeeIdemployeeNumberjobIdjobNumberjobTaskNumberabsencelineNumberdatequantitystatusunitOfMeasureIdunitOfMeasureCodelastModifiedDateTimeExample API Request
Create Time Entry:
Response:
{ "@odata.context": "https://api.businesscentral.dynamics.com/v2.0/{tenantId}/production/api/v2.0/$metadata#companies({companyId})/timeRegistrationEntries/$entity", "id": "12345678-90ab-cdef-1234-567890abcdef", "employeeId": "258bb9c0-44e3-ea11-bb43-000d3a2feca1", "employeeNumber": "EMP001", "date": "2025-10-27", "quantity": 3.5, "jobNumber": "PROJ-001", "jobTaskNumber": "TASK-100", "unitOfMeasureCode": "HOUR", "status": "Open", "lastModifiedDateTime": "2025-10-27T14:30:00Z" }Authentication Setup
1. Register App in Azure Portal
https://YOUR-EXTENSION-URL/auth-callback2. Configure API Permissions
3. Create Client Secret
4. Note Required Values
Error Handling
Common API errors and handling strategies:
Testing & Quality Assurance
Local Testing
Package Extension:
Upload to Test Publisher:
Install in Test Organization:
Test Scenarios:
Business Central Integration Testing
Browser Compatibility
Test in browsers commonly used with Azure DevOps:
Deployment & Publishing
Pre-Publishing Checklist
Marketplace Requirements
Required Documentation Files:
README.md - User guide with:
PRIVACY.md - Privacy policy covering:
TERMS.md - Terms of use covering:
Required Images:
Publishing Steps
Create Publisher Account:
Package Extension:
Publish to Marketplace:
Or upload manually via Publishing Portal
Complete Marketplace Listing:
Request Public Visibility:
Maintenance & Updates
Version Updates:
vss-extension.jsonImportant: You cannot change OAuth scopes after publishing. If scope changes are needed, you must unpublish, update, and republish the extension.
Security Considerations
Data Protection
Privacy
Authentication Best Practices
Future Enhancements
Consider adding these features in future versions:
Timer Functionality:
Dashboard Widget:
Bulk Operations:
Advanced Mapping:
Offline Support:
Reporting:
Approvals Workflow:
Reference Links
Azure DevOps Extension Development
Business Central API
Authentication & Security
Visual Studio Marketplace
Reference Implementations
Tools & SDKs
Community & Support
Support & Contributing
Getting Help
Contributing
Contributions are welcome! Please: