PSA/sdk/samples/node/create-service-and-manual-invoice.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

341 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export {};
/**
* Sample workflow: create a service catalog entry and then generate a manual invoice
* that references the new service.
*
* Usage:
* ALGA_API_URL="https://algapsa.com" \
* ALGA_API_KEY="your-api-key" \
* ALGA_TENANT_ID="your-tenant-id" \
* npm run sample:create-service-manual-invoice -- \
* --client-id "uuid-of-client" \
* --service-name "External Monitoring" \
* --service-type-id "uuid-of-service-type" \
* --billing-method fixed \
* --unit each \
* --rate 150.50 \
* --quantity 1
*
* Flags:
* --client-id Required. UUID of the client that should receive the manual invoice.
* --service-name Optional. Defaults to "Sample Service <timestamp>".
* --service-type-id Optional. Existing custom service type UUID. When omitted the script
* tries to reuse the type from the first service returned by GET /services.
* --billing-method Optional. One of fixed | hourly | usage. Defaults to fixed.
* --unit Optional. Unit of measure for the service (defaults to "each").
* --rate Optional. Dollar amount for the service default rate and invoice line
* (e.g. "150" or "150.50"). Converted to cents automatically. Mutually
* exclusive with --rate-cents.
* --rate-cents Optional. Integer cents value (e.g. 15000). Overrides --rate when provided.
* --quantity Optional. Quantity for the manual invoice line (defaults to 1).
* --invoice-description Optional. Custom manual invoice line description. Defaults to
* "Manual charge for <service-name>".
* --service-description Optional. Description stored on the service catalog item.
* --tax-rate-id Optional. UUID of an existing tax rate to attach to the service.
* --category-id Optional. UUID of a service category to assign.
*/
const API_BASE_URL = process.env.ALGA_API_URL ?? "https://algapsa.com";
const API_KEY = process.env.ALGA_API_KEY;
const TENANT_ID = process.env.ALGA_TENANT_ID;
if (!API_KEY) {
console.error("Missing ALGA_API_KEY environment variable");
process.exit(1);
}
type BillingMethod = "fixed" | "hourly" | "usage";
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
meta?: unknown;
}
interface ServiceResponse {
service_id: string;
service_name: string;
custom_service_type_id: string;
billing_method: BillingMethod;
default_rate: number;
unit_of_measure: string;
category_id: string | null;
tax_rate_id: string | null;
description: string | null;
tenant: string;
service_type_name?: string;
created_at?: string;
updated_at?: string;
}
interface ApiSuccessResponse<T> {
data: T;
meta?: unknown;
}
interface ApiErrorResponse {
error?: {
code?: string;
message?: string;
details?: unknown;
} | string;
[key: string]: unknown;
}
interface ManualInvoiceResponse {
invoice_id: string;
invoice_number: string;
client_id: string;
status: string;
subtotal: number;
tax: number;
total_amount: number;
is_manual: boolean;
}
interface ManualInvoiceLine {
service_id: string;
quantity: number;
description: string;
rate: number;
}
type FlagMap = Record<string, string>;
function parseFlags(): FlagMap {
const flags: FlagMap = {};
const argv = process.argv.slice(2);
for (let index = 0; index < argv.length; index++) {
const token = argv[index];
if (!token.startsWith("--")) {
continue;
}
const key = token.slice(2);
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
console.error(`Missing value for flag --${key}`);
process.exit(1);
}
flags[key] = value;
index += 1;
}
return flags;
}
function parseCents(flags: FlagMap): number {
if (flags["rate-cents"]) {
const cents = Number(flags["rate-cents"]);
if (!Number.isFinite(cents) || cents < 0) {
throw new Error(`Invalid --rate-cents value: ${flags["rate-cents"]}`);
}
return Math.round(cents);
}
const raw = flags["rate"] ?? "150";
const asNumber = Number(raw);
if (!Number.isFinite(asNumber) || asNumber < 0) {
throw new Error(`Invalid --rate value: ${raw}`);
}
return Math.round(asNumber * 100);
}
function parseQuantity(flags: FlagMap): number {
const raw = flags["quantity"] ?? "1";
const quantity = Number(raw);
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error(`Invalid --quantity value: ${raw}`);
}
return quantity;
}
async function apiRequest<T>(path: string, init?: RequestInit): Promise<Response> {
const headers: Record<string, string> = {
Accept: "application/json",
...init?.headers,
"x-api-key": API_KEY!,
};
if (TENANT_ID) {
headers["x-tenant-id"] = TENANT_ID;
}
if (init?.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
return await fetch(`${API_BASE_URL}${path}`, {
...init,
headers,
});
}
async function ensureServiceTypeId(flags: FlagMap): Promise<string> {
if (flags["service-type-id"]) {
return flags["service-type-id"];
}
const response = await apiRequest<PaginatedResponse<ServiceResponse>>(
"/api/v1/services?limit=1"
);
if (!response.ok) {
const detail = await safeJson<ApiErrorResponse>(response);
throw new Error(
`Unable to infer service type. GET /api/v1/services failed: ${response.status} ${response.statusText} ${describeError(detail)}`
);
}
const payload = (await response.json()) as PaginatedResponse<ServiceResponse>;
const first = payload.data?.[0];
if (!first?.custom_service_type_id) {
throw new Error(
"No existing services found. Provide --service-type-id so the script knows which service type to use."
);
}
return first.custom_service_type_id;
}
async function createService(flags: FlagMap, serviceTypeId: string, defaultRate: number) {
const billingMethod = (flags["billing-method"] ?? "fixed") as BillingMethod;
if (!["fixed", "hourly", "usage"].includes(billingMethod)) {
throw new Error(`Invalid --billing-method value: ${flags["billing-method"]}`);
}
const servicePayload: Record<string, unknown> = {
service_name: flags["service-name"] ?? `Sample Service ${Date.now()}`,
custom_service_type_id: serviceTypeId,
billing_method: billingMethod,
default_rate: defaultRate,
unit_of_measure: flags["unit"] ?? "each",
};
if (flags["service-description"]) {
servicePayload.description = flags["service-description"];
}
if (flags["tax-rate-id"]) {
servicePayload.tax_rate_id = flags["tax-rate-id"];
}
if (flags["category-id"]) {
servicePayload.category_id = flags["category-id"];
}
const response = await apiRequest<ApiSuccessResponse<ServiceResponse>>("/api/v1/services", {
method: "POST",
body: JSON.stringify(servicePayload),
});
if (!response.ok) {
const detail = await safeJson<ApiErrorResponse>(response);
throw new Error(
`Service creation failed: ${response.status} ${response.statusText} ${describeError(detail)}`
);
}
const body = (await response.json()) as ApiSuccessResponse<ServiceResponse>;
return body.data;
}
async function createManualInvoice(
clientId: string,
line: ManualInvoiceLine
): Promise<ManualInvoiceResponse> {
const payload = {
clientId,
items: [line],
};
const response = await apiRequest<ApiSuccessResponse<ManualInvoiceResponse>>("/api/v1/invoices/manual", {
method: "POST",
body: JSON.stringify(payload),
});
if (!response.ok) {
const detail = await safeJson<ApiErrorResponse>(response);
throw new Error(
`Manual invoice creation failed: ${response.status} ${response.statusText} ${describeError(detail)}`
);
}
const body = (await response.json()) as ApiSuccessResponse<ManualInvoiceResponse>;
return body.data;
}
async function safeJson<T>(response: Response): Promise<T | null> {
try {
return (await response.json()) as T;
} catch (error) {
void error;
return null;
}
}
function describeError(payload: ApiErrorResponse | null): string {
if (!payload) {
return "No error payload returned";
}
if (typeof payload.error === "string") {
return payload.error;
}
if (payload.error?.message) {
return payload.error.message;
}
return JSON.stringify(payload);
}
(async () => {
try {
const flags = parseFlags();
const clientId = flags["client-id"];
if (!clientId) {
console.error("Missing required --client-id flag");
process.exit(1);
}
const defaultRate = parseCents(flags);
const quantity = parseQuantity(flags);
const serviceTypeId = await ensureServiceTypeId(flags);
const service = await createService(flags, serviceTypeId, defaultRate);
const invoiceDescription =
flags["invoice-description"] ?? `Manual charge for ${service.service_name}`;
const invoice = await createManualInvoice(clientId, {
service_id: service.service_id,
quantity,
description: invoiceDescription,
rate: defaultRate,
});
console.log("Created service catalog entry:");
console.log(JSON.stringify(service, null, 2));
console.log("\nCreated manual invoice:");
console.log(JSON.stringify(invoice, null, 2));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
})();