Git Commit-to-Invoice Feature Specification

Overview

The Git Commit-to-Invoice feature enables IT freelancers and development teams to automatically transform code commits into billable invoice line items. By integrating with Git providers and project management tools (Jira, Linear, etc.), the system extracts contextual work data and generates draft invoice items for manual review and approval.


Core Philosophy

  • Human-in-the-loop: All commits become draft line items requiring explicit user approval before invoicing
  • Context-rich: Integrates with project management tools to provide meaningful descriptions for clients
  • No double-billing protection via persistence: Rejected or ignored commits are tracked in the database to prevent recurring suggestions
  • Flexible time tracking: Supports explicit time logging, heuristic estimation, or hybrid approaches

User Workflow

1. Initial Setup

User connects Git repository (GitHub/GitLab/Bitbucket)
    ↓
OAuth authentication with selected provider
    ↓
Webhook automatically configured for push events
    ↓
Optional: Connect Jira/Linear for ticket context enrichment

2. Commit Processing Pipeline

Git Push Event
    ↓
Cloudflare Worker receives webhook
    ↓
Commit metadata extracted (hash, author, message, timestamp, diff stats)
    ↓
Check: Commit already in database? → Skip if exists
    ↓
Parse ticket references (e.g., ABC-123) from commit message
    ↓
Fetch Jira/PM tool context (summary, epic, labels)
    ↓
Generate human-readable description
    ↓
Calculate time estimate (explicit, heuristic, or ML-based)
    ↓
Create DRAFT line item in Commit Inbox
    ↓
Notify user (in-app, email, or Slack)

3. User Review & Approval

Commit Inbox Interface:

  • List view of all unprocessed commits grouped by:
  • Ticket/Epic
  • Date range
  • Repository
  • Project

Per Line Item Actions:

  • Approve: Add to existing or new invoice draft
  • Edit: Modify time, rate, description before adding
  • Split: Divide across multiple invoice items or periods
  • Ignore: Mark as non-billable (persisted to prevent re-suggestion)
  • Defer: Keep in inbox for later decision

IT Ticketing System Integrations

Rank System Key Features Price Starting At
1 Zendesk Market leader, highly scalable, extensive features ~$19/agent/month
2 Freshdesk Very popular with small teams, free plan available, good balance ~$15/agent/month (free tier available)
3 Jira Service Management IT teams & DevOps, deeply integrated in the Atlassian ecosystem Free up to 3 agents, then ~$21/agent/month
4 Zoho Desk Very affordable, good integration with Zoho ecosystem ~$7-14/agent/month (free up to 3 agents)
5 Help Scout Minimalist, simple to use, focused on email support ~$25/agent/month

Integration Benefits

Connecting a ticketing system enriches commit data with business context:

  • Ticket summaries provide client-friendly descriptions
  • Epic/Story hierarchy enables logical grouping for invoices
  • Status tracking ensures only completed work is billed
  • Time tracking sync from tickets can validate estimated hours
  • Labels/categories help identify billable vs. non-billable work automatically

Data Model

Commit Entity

interface GitCommit {
  id: string; // Internal UUID
  hash: string; // Git SHA (partial or full)
  shortHash: string; // First 7 characters
  message: string; // Full commit message
  authorName: string;
  authorEmail: string;
  committedAt: DateTime;

  // Repository context
  repositoryId: string;
  branch: string;

  // Diff statistics
  filesChanged: number;
  additions: number;
  deletions: number;

  // Processing status
  status: "pending" | "drafted" | "invoiced" | "ignored";

  // Timestamps
  createdAt: DateTime;
  updatedAt: DateTime;
  ignoredAt?: DateTime; // When marked as ignored
  invoicedAt?: DateTime; // When added to invoice

  // Relations
  projectId: string; // Links to Meister Bill project
  ticketRef?: string; // e.g., "ABC-123"
  draftItems: DraftLineItem[];
}

Draft Line Item Entity

interface DraftLineItem {
  id: string;

  // Source tracking
  sourceType: "git" | "git+jira" | "git+linear" | "manual";
  commitIds: string[]; // Can aggregate multiple commits

  // Content
  description: string; // Human-readable work description
  detailedDescription?: string; // Technical details for internal use

  // Time & Pricing
  hours: number; // Billable hours
  hourlyRate: number; // From project configuration
  total: number; // Calculated: hours × rate

  // Date range (for period-based billing)
  startDate: Date;
  endDate: Date;

  // Categorization
  category?: string; // Epic, component, or custom tag
  ticketKey?: string; // Jira/PM tool reference
  ticketUrl?: string;

  // Status workflow
  status: "draft" | "approved" | "invoiced" | "rejected";

  // Relations
  projectId: string;
  invoiceId?: string; // Populated when added to invoice

  // Timestamps
  createdAt: DateTime;
  approvedAt?: DateTime;
}

Project Configuration

interface ProjectGitConfig {
  projectId: string;

  // Git provider settings
  provider: "github" | "gitlab" | "bitbucket";
  repositoryUrl: string;
  webhookSecret: string;

  // Time estimation strategy
  timeEstimation: {
    strategy: "explicit" | "heuristic" | "hybrid";
    explicitPattern?: string; // Regex to extract time from commit msg
    heuristicFactor?: number; // Minutes per LOC changed
    defaultHours?: number; // Fallback for unparseable commits
  };

  // Hourly rate (fixed per project)
  hourlyRate: number;
  currency: string;

  // Integration settings
  jiraConfig?: {
    instanceUrl: string;
    projectKey: string;
    authToken: string;
  };

  // Filtering rules
  excludePatterns: string[]; // e.g., ["^docs:", "^test:"]
  includeOnlyTickets: boolean; // Only commits with ticket refs
  billableLabels: string[]; // Jira labels that indicate billability
}

Integration Architecture

1. Git Provider Webhooks

GitHub:

POST /api/webhooks/github
X-GitHub-Event: push
X-Hub-Signature-256: sha256=...

GitLab:

POST /api/webhooks/gitlab
X-Gitlab-Event: Push Hook
X-Gitlab-Token: ...

Bitbucket:

POST /api/webhooks/bitbucket
X-Event-Key: repo:push

2. Jira Integration (Optional)

Authentication: OAuth 2.0 (3LO) for Jira Cloud, PAT for Server

API Usage:

// Fetch issue details
GET /rest/api/3/issue/{issueKey}

// Response enrichment
{
  "key": "ABC-123",
  "fields": {
    "summary": "Fix authentication bug",
    "issuetype": { "name": "Bug" },
    "labels": ["billable", "backend"],
    "customfield_10014": "EPIC-456",
    "status": { "name": "Done" }
  }
}

Rate Limiting: 10 requests/second with exponential backoff

3. Alternative PM Tools

Tool Auth API Endpoint
Linear Personal API Key https://api.linear.app/graphql
Asana OAuth 2.0 https://app.asana.com/api/1.0/tasks/{gid}
ClickUp Personal Token https://api.clickup.com/api/v2/task/{task_id}
GitHub Issues GitHub App https://api.github.com/repos/{owner}/{repo}/issues/{number}

Description Generation Strategies

Strategy 1: Simple (No AI)

Input: Commit message + file statistics Output:

"Backend Development: Fixed authentication bug
(2 files changed, +45/-12 lines)"

Implementation: Template-based with pattern matching

Strategy 2: Diff Summary (AI-Powered)

Input: Commit diff (truncated to 500 LOC) + commit message + Jira context Prompt:

Summarize the following code changes for a non-technical client invoice.
Focus on business value, not technical implementation details.
Be concise (max 20 words).

Commit: "ABC-123: Fix auth bug"
Jira: "User Authentication Fix - Critical bug allowing unauthorized access"
Changes: auth/middleware.ts (+45/-12 lines)

Output:

"Fixed critical authentication vulnerability in user login system"

Model: Claude 3 Haiku or GPT-4-mini (cost-optimized)

Strategy 3: Aggregated Sprint Summary

Use Case: Weekly/monthly invoicing with rolled-up commits

Input: All commits per Epic/Ticket over date range Output:

"API Modernization Sprint (Jan 15-31):
- Implemented OAuth2 authentication flow
- Migrated 3 legacy endpoints to REST
- Added comprehensive error handling
Total: 24 hours @ $150/hr = $3,600"

Time Estimation Methods

Method A: Explicit Time Logging

Commit Message Convention:

ABC-123 [2.5h]: Refactored user service

Pattern: \[(\d+\.?\d*)h?\] or configurable regex

Method B: Heuristic Estimation

Formula:

baseTime = 15 minutes  // Minimum per commit
fileTime = filesChanged × 5 minutes
lineTime = (additions + deletions) × 0.5 minutes
total = baseTime + fileTime + lineTime (capped at 8 hours)

Calibration: User can adjust factor per project after initial data

  1. Parse explicit time from commit message
  2. If missing, apply heuristic
  3. If heuristic seems wrong (e.g., 1000 LOC change = 8+ hours), flag for review

Technical Implementation

Database Schema (Supabase)

-- Commits table
CREATE TABLE commits (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  hash VARCHAR(40) UNIQUE NOT NULL,
  short_hash VARCHAR(7) NOT NULL,
  message TEXT NOT NULL,
  author_name VARCHAR(255),
  author_email VARCHAR(255),
  committed_at TIMESTAMPTZ NOT NULL,
  repository_id UUID REFERENCES repositories(id),
  branch VARCHAR(255),
  files_changed INTEGER DEFAULT 0,
  additions INTEGER DEFAULT 0,
  deletions INTEGER DEFAULT 0,
  status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'drafted', 'invoiced', 'ignored')),
  project_id UUID REFERENCES projects(id) NOT NULL,
  ticket_ref VARCHAR(50),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  ignored_at TIMESTAMPTZ,
  invoiced_at TIMESTAMPTZ
);

-- Draft line items
CREATE TABLE draft_line_items (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source_type VARCHAR(20) NOT NULL,
  description TEXT NOT NULL,
  detailed_description TEXT,
  hours DECIMAL(5,2) NOT NULL,
  hourly_rate DECIMAL(10,2) NOT NULL,
  total DECIMAL(10,2) GENERATED ALWAYS AS (hours * hourly_rate) STORED,
  start_date DATE NOT NULL,
  end_date DATE NOT NULL,
  category VARCHAR(100),
  ticket_key VARCHAR(50),
  ticket_url TEXT,
  status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'invoiced', 'rejected')),
  project_id UUID REFERENCES projects(id) NOT NULL,
  invoice_id UUID REFERENCES invoices(id),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  approved_at TIMESTAMPTZ
);

-- Junction table: commits to draft items (many-to-many)
CREATE TABLE commit_draft_items (
  commit_id UUID REFERENCES commits(id) ON DELETE CASCADE,
  draft_item_id UUID REFERENCES draft_line_items(id) ON DELETE CASCADE,
  PRIMARY KEY (commit_id, draft_item_id)
);

-- Indexes for performance
CREATE INDEX idx_commits_project_status ON commits(project_id, status);
CREATE INDEX idx_commits_hash ON commits(hash);
CREATE INDEX idx_commits_committed_at ON commits(committed_at);
CREATE INDEX idx_draft_items_project_status ON draft_line_items(project_id, status);

Cloudflare Worker (Webhook Handler)

// src/workers/git-webhook.ts
export interface Env {
  SUPABASE_URL: string;
  SUPABASE_SERVICE_KEY: string;
  WEBHOOK_SECRET_GITHUB: string;
  WEBHOOK_SECRET_GITLAB: string;
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<Response> {
    const url = new URL(request.url);
    const provider = url.pathname.split("/").pop();

    // 1. Verify webhook signature
    const isValid = await verifyWebhook(request, provider, env);
    if (!isValid) return new Response("Unauthorized", { status: 401 });

    // 2. Parse payload
    const payload = await request.json();
    const commits = extractCommits(payload, provider);

    // 3. Queue for processing (async)
    ctx.waitUntil(processCommits(commits, env));

    return new Response("Accepted", { status: 202 });
  },
};

async function processCommits(commits: CommitPayload[], env: Env) {
  const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY);

  for (const commit of commits) {
    // Idempotency check
    const { data: existing } = await supabase
      .from("commits")
      .select("id")
      .eq("hash", commit.hash)
      .single();

    if (existing) continue;

    // Fetch project configuration
    const { data: project } = await supabase
      .from("projects")
      .select("*, git_config(*)")
      .eq("repository_id", commit.repositoryId)
      .single();

    if (!project || !project.git_config) continue;

    // Apply exclusion filters
    if (shouldExclude(commit, project.git_config)) continue;

    // Enrich with Jira if configured
    let ticketContext = null;
    if (project.git_config.jira_config && commit.ticketRef) {
      ticketContext = await fetchJiraContext(
        commit.ticketRef,
        project.git_config.jira_config,
      );
    }

    // Generate description
    const description = await generateDescription(
      commit,
      ticketContext,
      project.git_config.description_strategy,
    );

    // Calculate time
    const hours = calculateHours(commit, project.git_config.time_estimation);

    // Insert commit
    const { data: commitRecord } = await supabase
      .from("commits")
      .insert({
        hash: commit.hash,
        short_hash: commit.shortHash,
        message: commit.message,
        author_name: commit.authorName,
        author_email: commit.authorEmail,
        committed_at: commit.timestamp,
        repository_id: commit.repositoryId,
        branch: commit.branch,
        files_changed: commit.filesChanged,
        additions: commit.additions,
        deletions: commit.deletions,
        status: "pending",
        project_id: project.id,
        ticket_ref: commit.ticketRef,
      })
      .select()
      .single();

    // Create draft line item
    await supabase.from("draft_line_items").insert({
      source_type: ticketContext ? "git+jira" : "git",
      description: description,
      detailed_description: commit.message,
      hours: hours,
      hourly_rate: project.git_config.hourly_rate,
      start_date: commit.timestamp,
      end_date: commit.timestamp,
      category: ticketContext?.epic || commit.branch,
      ticket_key: ticketContext?.key,
      ticket_url: ticketContext?.url,
      status: "draft",
      project_id: project.id,
    });

    // Notify user
    await notifyUser(project.user_id, "new_commits_pending");
  }
}

Frontend: Commit Inbox Component

<!-- components/CommitInbox.vue -->
<template>
  <div class="commit-inbox">
    <div class="filters">
      <select v-model="groupBy">
        <option value="ticket">Group by Ticket</option>
        <option value="date">Group by Date</option>
        <option value="epic">Group by Epic</option>
      </select>
      <label>
        <input v-model="showIgnored" type="checkbox" />
        Show ignored commits
      </label>
    </div>

    <div v-for="group in groupedCommits" :key="group.key" class="commit-group">
      <h3>{{ group.title }} ({{ group.totalHours }}h)</h3>

      <div v-for="item in group.items" :key="item.id" class="draft-item">
        <div class="item-header">
          <input
            v-model="item.selected"
            type="checkbox"
            @change="updateSelection"
          />
          <span class="ticket-badge">{{ item.ticketKey }}</span>
          <span class="date">{{ formatDate(item.startDate) }}</span>
        </div>

        <div class="item-content">
          <input
            v-model="item.description"
            class="description-input"
            placeholder="Work description..."
          />
          <div class="meta">
            <input
              v-model.number="item.hours"
              type="number"
              step="0.25"
              class="hours-input"
            />
            <span>@</span>
            <span class="rate">{{ item.hourlyRate }}/hr</span>
            <span class="total">= ${{ item.total }}</span>
          </div>
        </div>

        <div class="item-actions">
          <button @click="addToInvoice(item)">Add to Invoice</button>
          <button @click="ignoreCommit(item)">Ignore</button>
          <button @click="showDetails(item)">Details</button>
        </div>
      </div>
    </div>

    <div class="bulk-actions" v-if="selectedItems.length > 0">
      <button @click="bulkAddToInvoice">
        Add {{ selectedItems.length }} to Invoice
      </button>
      <button @click="bulkIgnore">Ignore Selected</button>
    </div>
  </div>
</template>

Security Considerations

Webhook Security

  • HMAC signature verification for all providers
  • IP allowlisting where supported (GitHub, GitLab)
  • Webhook secrets rotated per project

Data Privacy

  • Commit messages may contain sensitive info → sanitize before AI processing
  • Jira tokens encrypted at rest (AES-256)
  • Diff content processed in-memory only, never persisted (unless user opts in)

Access Control

  • Users can only access commits for their own projects
  • Row Level Security (RLS) policies on all tables
  • Repository access verified via Git provider API on setup

Pricing & Packaging

Plan Git Integration AI Descriptions PM Tool Connect
Starter Basic (last 30 days)
Professional Unlimited history ✅ Standard 1 connection
Agency Unlimited + team features ✅ Advanced Unlimited

Add-on Option

  • Git Integration: $9/month (if not included in base plan)
  • AI Description Upgrade: $5/month (better models, longer context)

Future Enhancements

Phase 2: Advanced Features

  • Sprint-based invoicing: Auto-aggregate by sprint/iteration
  • Team allocation: Split commits by contributor for team billing
  • Client portal: Read-only view of work logs for transparency
  • GitLab CI/CD integration: Track deployment time as billable hours

Phase 3: Intelligence

  • ML time estimation: Train on user's historical data
  • Anomaly detection: Flag unusual commit patterns (potential errors)
  • Smart grouping: AI-suggested invoice groupings by logical work units

Success Metrics

  • Adoption: % of projects with Git integration enabled
  • Efficiency: Average time from commit to invoiced (target: < 5 minutes)
  • Accuracy: User edit rate on AI-generated descriptions (target: < 20%)
  • Revenue: Additional MRR from Git integration add-on

Open Questions

  1. Should we support pre-commit hooks to enforce time logging conventions?
  2. How to handle force-pushes with rewritten history (new commit hashes)?
  3. Should ignored commits be permanently hidden or recoverable?
  4. Do we need Git blame analysis to handle pair programming scenarios?