From halopsa
Guides HaloPSA contract management: service agreements, recurring billing, prepaid hours, renewals, types, SLAs, and financial workflows for MSPs.
npx claudepluginhub wyre-technology/msp-claude-plugins --plugin halopsaThis skill uses the workspace's default tool permissions.
Contracts in HaloPSA define the service relationship with clients - what services you provide, how you bill for them, and what service levels apply. Contracts control how time, expenses, and recurring charges flow to invoices and are critical for MSP financial management.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Analyzes BMad project state from catalog CSV, configs, artifacts, and query to recommend next skills or answer questions. Useful for help requests, 'what next', or starting BMad.
Contracts in HaloPSA define the service relationship with clients - what services you provide, how you bill for them, and what service levels apply. Contracts control how time, expenses, and recurring charges flow to invoices and are critical for MSP financial management.
| Type | Description | Billing Method |
|---|---|---|
| Recurring | Monthly/annual managed services | Fixed recurring fee |
| Prepaid Hours | Block hours/time bank | Deduct from balance |
| Ad-Hoc | Pay as you go (T&M) | Bill actual time |
| Project | Fixed-price project | Milestone billing |
| Warranty | Coverage period | No direct billing |
| Field | Type | Required | Description |
|---|---|---|---|
id | int | System | Unique identifier |
ref | string | Yes | Contract reference/name |
client_id | int | Yes | Associated client |
startdate | date | Yes | Contract start |
enddate | date | No | Contract end |
status | string | Yes | Contract status |
type | string | Yes | Contract type |
| Field | Type | Required | Description |
|---|---|---|---|
billingfrequency | string | No | Monthly, Quarterly, Annual |
invoiceday | int | No | Day of month to invoice |
taxcode | string | No | Tax code for invoicing |
currency_code | string | No | Billing currency |
poref | string | No | Purchase order reference |
| Field | Type | Required | Description |
|---|---|---|---|
sla_id | int | No | Associated SLA |
priority_id | int | No | Default ticket priority |
includesallsites | bool | No | Covers all client sites |
includesallassets | bool | No | Covers all assets |
| Field | Type | Required | Description |
|---|---|---|---|
value | decimal | No | Contract value |
setupfee | decimal | No | One-time setup fee |
renewalvalue | decimal | No | Renewal amount |
marginpercent | decimal | No | Target margin |
| Status | Description | Billing |
|---|---|---|
| Active | In effect | Billable |
| Pending | Not yet started | Not billable |
| Expired | Past end date | Not billable |
| Cancelled | Terminated early | Not billable |
| On Hold | Temporarily paused | Not billable |
POST /api/ClientContract
Authorization: Bearer {token}
Content-Type: application/json
[
{
"ref": "Acme Corp - Managed Services 2024",
"client_id": 123,
"type": "Recurring",
"status": "Active",
"startdate": "2024-01-01",
"enddate": "2024-12-31",
"billingfrequency": "Monthly",
"invoiceday": 1,
"value": 2500.00,
"sla_id": 1,
"includesallsites": true,
"includesallassets": true,
"notes": "Premium support tier, includes unlimited remote support"
}
]
{
"contracts": [
{
"id": 5001,
"ref": "Acme Corp - Managed Services 2024",
"client_id": 123,
"client_name": "Acme Corporation",
"status": "Active",
"startdate": "2024-01-01",
"enddate": "2024-12-31"
}
]
}
By client:
GET /api/ClientContract?client_id=123
Active contracts:
GET /api/ClientContract?status=Active
Expiring soon:
GET /api/ClientContract?enddate_before=2024-03-31&enddate_after=2024-01-01&status=Active
By type:
GET /api/ClientContract?type=Recurring
GET /api/ClientContract/5001
With recurring items:
GET /api/ClientContract/5001?includerecurringinvoiceitems=true
POST /api/ClientContract
Authorization: Bearer {token}
Content-Type: application/json
[
{
"id": 5001,
"status": "On Hold",
"notes": "Client requested temporary pause - resume Feb 2024"
}
]
Recurring items are the line items that generate recurring invoices.
| Field | Type | Required | Description |
|---|---|---|---|
id | int | System | Unique identifier |
contract_id | int | Yes | Parent contract |
description | string | Yes | Line item description |
quantity | decimal | Yes | Quantity |
unitprice | decimal | Yes | Price per unit |
billingfrequency | string | No | Override contract frequency |
startdate | date | No | Item start date |
enddate | date | No | Item end date |
POST /api/RecurringInvoiceItem
Authorization: Bearer {token}
Content-Type: application/json
[
{
"contract_id": 5001,
"description": "Managed Workstation Support",
"quantity": 25,
"unitprice": 50.00,
"startdate": "2024-01-01"
},
{
"contract_id": 5001,
"description": "Server Management",
"quantity": 3,
"unitprice": 200.00,
"startdate": "2024-01-01"
},
{
"contract_id": 5001,
"description": "M365 Business Premium Licenses",
"quantity": 25,
"unitprice": 25.00,
"startdate": "2024-01-01"
}
]
[
{
"id": 10001,
"quantity": 30,
"notes": "Added 5 workstations in March"
}
]
| Field | Type | Description |
|---|---|---|
prepaid_hours | decimal | Total hours purchased |
prepaid_hours_used | decimal | Hours consumed |
prepaid_hours_remaining | decimal | Available balance |
hourlyrate | decimal | Rate per hour |
[
{
"ref": "Acme Corp - Prepaid Hours Q1 2024",
"client_id": 123,
"type": "Prepaid Hours",
"status": "Active",
"startdate": "2024-01-01",
"enddate": "2024-03-31",
"prepaid_hours": 40,
"hourlyrate": 150.00,
"value": 6000.00
}
]
GET /api/ClientContract/5002?includehoursummary=true
Response includes:
{
"prepaid_hours": 40,
"prepaid_hours_used": 12.5,
"prepaid_hours_remaining": 27.5
}
Time entries against tickets linked to prepaid contracts automatically deduct hours:
{
"ticket_id": 54321,
"timetaken": 60,
"contract_id": 5002
}
Link contracts to Service Level Agreements:
[
{
"id": 5001,
"sla_id": 1
}
]
Tickets under contract inherit SLA settings:
Create contract
Add recurring items
Link to assets (optional)
Configure billing
Identify expiring contracts
GET /api/ClientContract?enddate_before=2024-03-31&status=Active
Review performance
async function reviewContractPerformance(contractId) {
const contract = await getContract(contractId);
const tickets = await getContractTickets(contractId);
return {
totalTickets: tickets.length,
slaCompliance: calculateSLACompliance(tickets),
hoursUsed: calculateHoursUsed(tickets),
profitability: calculateProfitability(contract, tickets)
};
}
Generate renewal
[
{
"ref": "Acme Corp - Managed Services 2025",
"client_id": 123,
"type": "Recurring",
"status": "Pending",
"startdate": "2025-01-01",
"enddate": "2025-12-31",
"renewalvalue": 2750.00,
"notes": "Renewal from contract 5001"
}
]
Expire old contract
[{ "id": 5001, "status": "Expired" }]
async function checkPrepaidBalance(contractId) {
const contract = await getContract(contractId);
const threshold = 10; // hours
if (contract.prepaid_hours_remaining <= threshold) {
return {
alert: true,
message: `Only ${contract.prepaid_hours_remaining} hours remaining`,
suggestedAction: 'Create replenishment quote'
};
}
return { alert: false };
}
async function reconcileContractBilling(contractId, period) {
const contract = await getContract(contractId);
const invoices = await getContractInvoices(contractId, period);
const timeEntries = await getContractTime(contractId, period);
const expectedRecurring = calculateRecurringTotal(contract);
const actualBilled = invoices.reduce((sum, i) => sum + i.total, 0);
const unbilledTime = timeEntries.filter(t => !t.invoiced);
return {
contract_id: contractId,
period,
expected_recurring: expectedRecurring,
actual_billed: actualBilled,
unbilled_time_entries: unbilledTime.length,
unbilled_amount: unbilledTime.reduce((sum, t) => sum + (t.amount || 0), 0)
};
}
| Code | Message | Resolution |
|---|---|---|
| 400 | ref required | Contract needs a reference name |
| 400 | client_id required | Must associate with a client |
| 400 | Invalid type | Use valid contract type |
| 400 | enddate before startdate | Fix date sequence |
| 404 | Contract not found | Verify contract ID |
| 409 | Cannot delete - has invoices | Cancel instead of delete |
function validateContract(contract) {
const errors = [];
if (!contract.ref || contract.ref.trim() === '') {
errors.push('Contract reference is required');
}
if (!contract.client_id) {
errors.push('Client ID is required');
}
if (!contract.startdate) {
errors.push('Start date is required');
}
if (contract.enddate && contract.startdate > contract.enddate) {
errors.push('End date must be after start date');
}
if (contract.type === 'Prepaid Hours' && !contract.prepaid_hours) {
errors.push('Prepaid hours contracts require hours allocation');
}
return {
isValid: errors.length === 0,
errors
};
}
GET /api/ClientContract?groupby=status&count=true
async function getExpiringContracts(days = 90) {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + days);
const contracts = await searchContracts({
status: 'Active',
enddate_before: futureDate.toISOString().split('T')[0]
});
return contracts.map(c => ({
id: c.id,
ref: c.ref,
client_name: c.client_name,
enddate: c.enddate,
value: c.value,
days_remaining: Math.ceil(
(new Date(c.enddate) - new Date()) / (1000 * 60 * 60 * 24)
)
}));
}
async function getContractValueByClient() {
const clients = await fetchAllClients();
const results = [];
for (const client of clients) {
const contracts = await getClientContracts(client.id, { status: 'Active' });
const totalValue = contracts.reduce((sum, c) => sum + (c.value || 0), 0);
results.push({
client_id: client.id,
client_name: client.name,
active_contracts: contracts.length,
annual_value: totalValue
});
}
return results.sort((a, b) => b.annual_value - a.annual_value);
}