Skip to content

⚡️ Bounty: 200,000 Sats — Build Business Central Time Tracking Extension for Azure DevOps #1

@BenGWeeks

Description

@BenGWeeks

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

  1. 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
  2. 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)
  3. 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

  1. Node.js and npm (latest LTS version)
  2. TFX CLI: Install globally
    npm install -g tfx-cli
  3. Visual Studio Code (recommended IDE)
  4. Azure DevOps Organization (for testing)
  5. Business Central Environment (for API testing)

Azure/Microsoft Setup

  1. Publisher Account: Create a publisher on Visual Studio Marketplace Publishing Portal
  2. 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
  3. 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:

  1. Use a backend proxy server to handle OAuth flow
  2. Never expose client secrets in browser code
  3. Implement PKCE (Proof Key for Code Exchange) for public clients
  4. 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

  1. Package Extension:

    tfx extension create --manifest-globs vss-extension.json
  2. Upload to Test Publisher:

    • Use a separate test publisher account
    • Upload VSIX file via Publishing Portal
    • Share with your test organization
  3. Install in Test Organization:

    • Go to Organization Settings > Extensions
    • Browse marketplace and search for your extension
    • Install to your organization
  4. 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

  1. Set up test Business Central environment
  2. Create test employee record
  3. Verify API permissions are granted
  4. Test time entry creation via Postman/Insomnia first
  5. Verify entries appear in BC timesheet interface
  6. Test job number associations
  7. 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

  • Complete and test all features
  • Create all required documentation (README, PRIVACY, TERMS)
  • Design and create extension images (logo, icon, preview)
  • Set up verified publisher account
  • Define pricing model (free or paid)
  • Prepare marketplace listing content
  • Complete security review
  • Test with multiple users and organizations

Marketplace Requirements

Required Documentation Files:

  1. README.md - User guide with:

    • Feature overview
    • Installation instructions
    • Configuration steps
    • Usage examples
    • Screenshots
    • Support contact information
  2. PRIVACY.md - Privacy policy covering:

    • What data is collected
    • How data is used
    • Data storage locations
    • User rights
    • Contact information
  3. 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

  1. Create Publisher Account:

  2. Package Extension:

    tfx extension create --manifest-globs vss-extension.json
  3. Publish to Marketplace:

    tfx extension publish --manifest-globs vss-extension.json --share-with YOUR_ORG

    Or upload manually via Publishing Portal

  4. Complete Marketplace Listing:

    • Add detailed description
    • Upload screenshots
    • Add support links
    • Set categories and tags
    • Define pricing
  5. 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:

  1. Timer Functionality:

    • Start/stop timer for real-time tracking
    • Automatic time entry creation from timer
    • Pause/resume capability
  2. Dashboard Widget:

    • Overview of all time entries across work items
    • Weekly/monthly summaries
    • Visual charts and graphs
  3. Bulk Operations:

    • Copy time entries from previous days
    • Bulk edit or delete
    • Export to CSV
  4. Advanced Mapping:

    • Auto-map work items to BC jobs
    • Custom field mapping
    • Team/project-level configurations
  5. Offline Support:

    • Queue time entries when offline
    • Sync when connection restored
    • Conflict resolution
  6. Reporting:

    • Generate time reports
    • Filter by date range, project, user
    • Export to PDF or Excel
  7. 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:

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes with tests
  4. Submit a pull request
  5. Follow coding standards and conventions
  6. Include a video of the widget working in DevOps (woritem)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions