Skip to content
campaiDeveloper
campai.com

Your equipment coordinator has a usage log: a member borrowed a trailer for three sessions, and the treasurer needs an invoice draft with the right customer and price. Build a desk that lets an administrator review that record, create the draft once, and find the saved result after a reload.

This is a Developer App hosted inside campai. Use it when your team records or reviews usage inside campai. If a shop, booking service or training platform supplies usage automatically, use an external API integration with its own durable job ledger. The reservation and reconciliation pattern here also applies to that service.

What you’ll have at the end

An internal usage desk that previews a reviewed price, creates one unsent invoice draft per stable reference, and preserves uncertain outcomes for reconciliation.

  1. Administrator enters confirmed usage
  2. Server recomputes the reviewed invoice payload
  3. App database reserves the reference before creating
  4. Built-in API creates an unsent draft and verifies its saved values
Before you start
  • Permission to create an Admin-scoped Developer App Widget
  • Temporary test access to its database and reviewed invoice Read/Insert server permissions
  • One designated test debtor and approved service, price, currency, and accounting allocation
On this page

Start with one service and one designated test debtor. Agree on a stable reference from the source log, such as EQUIPMENT-2026-001. A row number that changes when the spreadsheet is sorted is not a stable reference.

For the first test, use three units at a gross unit price of 1500 minor units. The expected total is 4500, or 45.00 in the mandate's actual currency. No currency conversion happens. These are test amounts; configure the price your club has approved before real use.

Use a separate unpublished app for the trial. The widget stores customer labels, billing addresses and invoice job records in its app database. Everyone allowed to use the widget can see them.

  1. Open More > Developers > Apps, create Usage invoice drafts, and add a Widget group named Usage desk.
  2. Choose Admin Scope and require Invoices and receipts > Read / Insert from visitors.
  3. In Settings, grant the app server Mandates > Read and Invoices and receipts > Read / Insert.
  4. Enable Database access and leave Outbound hosts empty.

The visitor requirement and server grant are separate. Visitors must already have the required permissions. The managed server key's grant applies across the organization. This example fixes a mandate and permitted customers in server code; those checks do not narrow the credential itself.

Copy the complete source at the end of this guide into the editor:

Source file Destination
server.ts The group's server index.ts
widget.tsx Widget > index.tsx
widget.css Widget > index.css, or retain its existing Tailwind import

Set MANDATE_ID and CURRENCY in the server. Configure the test customer and service with your treasurer:

Setting What to enter
Customer account The designated debtor/customer account number.
Customer address That debtor's reviewed billing address.
Service account The revenue account for this service.
Service unitAmount The gross unit price in integer minor units.
Service taxCode The applicable configured tax code; null sends no tax code.
costCenter1 / costCenter2 The accounting allocation required by the mandate.

The two account numbers have different purposes. Neither is a contact ID. Some accounting plans require costCenter1 because they use regions, including when creating drafts. Verify these settings in campai before enabling a write. Read Understand money, dates, and data types for amount and calendar-date handling.

Leave ALLOW_DRAFTS set to false. Set DISPLAY_LOCALE in the widget if your team uses a different number format; the currency still comes from the server configuration. Save the source, select the Widget's index.tsx, and start Usage desk: Simulate. The desk uses the built-in api and storage objects. It needs no external server, API key pasted into code, or package installation.

Enter EQUIPMENT-2026-001, select the configured test customer and service, set quantity to 3, and enter the invoice and due dates. Select Preview draft.

Check the customer, address, gross total, debtor account, revenue account, tax code and cost centers. Previewing does not reserve the reference or create a receipt. Changing a form field clears the preview so you review the revised values again.

Review the recipient, amount, dates, and accounting allocation before creating the draft.

Review the recipient, amount, dates, and accounting allocation before creating the draft.

Usage form and review panel for three equipment sessions, showing the customer, 45.00 total, and account allocations

If the preview rejects an unconfigured account, finish the server configuration first. Do not replace placeholders with arbitrary accounts merely to make validation pass. A successful preview checks the example's inputs; it does not prove that campai accepts your accounting configuration.

After checking the preview, set ALLOW_DRAFTS to true, save, and restart the simulation. Enter the same synthetic usage record and preview it again. Select Create this draft once.

The server first reserves the usage reference in its database with an atomic check. Only the request that wins that check sends the create. It then stores the returned receipt ID and reads that receipt back. Verified draft means the customer, dates, position, account allocations, total and draft state matched the reserved payload.

A saved receipt ID remains available even when verification needs to be completed.

A saved receipt ID remains available even when verification needs to be completed.

Saved usage references with one verified draft and another created draft awaiting read-back
Compare the saved invoice position with the usage you reviewed. Choose accounts from your own accounting setup.

Compare the saved invoice position with the usage you reviewed. Choose accounts from your own accounting setup.

Native invoice positions showing three sessions at 15.00, a 45.00 total, revenue account, and cost center

Reload the desk. The same job and receipt ID should return. In Payments > Invoices and donations, find the test customer, select its Drafted receipt, and open Edit receipt. Compare the title and notes with the usage reference, then inspect the recipient, No sending setting, positions, account allocations, and total. Close with Cancel when you only want to inspect it; Create Invoice belongs to the separate issuing workflow. This example requests no email, payment, deposit allocation or finalization. Your normal invoice review and issuing process happens separately.

Submit the same reference again. The server must reject it as already reserved, even after the first invoice was verified. Open two views and try the same reference from both; at most one request may create the draft.

The reservation belongs to this app and mandate. A second copy of the app has its own database and cannot see the first copy's jobs. Keep one desk authoritative for the source log, and retain the same reference throughout investigation. Changing the reference to bypass an error defeats duplicate protection.

The create call and app database are separate systems. A lost response can leave a draft in campai even though the widget shows an error. The app keeps the reference reserved and blocks further creates until you reload.

Saved state Meaning Next action
Reserved · inspect The request may still be running, may have failed, or may have created a draft without returning its ID. Inspect campai using the usage reference in the title and note. Do not create again.
Created · verify A receipt ID was saved, but read-back was not completed or did not match. Open that receipt, investigate the mismatch, then verify the existing ID again.
Verified draft The saved receipt matched the reserved payload. Continue your normal invoice review using that receipt.

Choose Review outcome, enter the matching receipt's full ID if it is not already saved, and select Verify existing draft. For a reserved job, wait at least two minutes so its original request has time to finish. Confirm that exactly one matching draft exists before reconciling. Verification never calls create.

Verify the existing receipt; keep the same usage reference throughout recovery.

Verify the existing receipt; keep the same usage reference throughout recovery.

Recovery dialog with the saved receipt ID and Verify existing draft action

If no receipt exists, preserve the reserved job and investigate the request failure. This small example does not unlock failed jobs automatically, even after a validation error. Add an explicit, reviewed recovery procedure before adopting it for unattended work; do not clear the database to make a retry possible.

Check that the three-unit test produced one draft totaling 45.00 in the configured currency, that the draft remains unsent, and that its reference survives reload. Test a repeated reference, a failed read-back and a changed preview. A date before the invoice date, a fractional quantity, or an unconfigured customer must fail before a write.

The desk lists at most 100 jobs. Add pagination and a retention process before growing beyond this small workflow. Saved job records are duplicate-prevention evidence and contain billing data. Review both access and retention before introducing real customers. Test the published audience separately as described in Publish and share an app.

These files run in campai’s hosted app runtime. Copy each complete file into its destination, or download all four files together.

The complete usage-to-invoice desk

Copy the server, widget, and CSS into an Admin Widget group. Configure the mandate, debtor, service price, and accounting allocation. Draft creation starts disabled.

Download ZIP
View and copy the complete source

The complete usage-to-invoice desk

// Hosted Widget server. Configure these values with your treasurer before enabling writes.
const MANDATE_ID = 'YOUR_MANDATE_ID';
const CURRENCY = 'EUR'; // The mandate's actual currency; this never converts amounts.
const ALLOW_DRAFTS = false;
const CUSTOMERS = [
    {
        key: 'test-customer',
        name: 'Example customer',
        account: 0,
        address: { country: 'DE', zip: '10115', city: 'Berlin', addressLine: 'Example street 1' },
    },
];
const SERVICES = [
    {
        key: 'equipment-session',
        name: 'Equipment use',
        unit: 'session',
        unitAmount: 1500,
        account: 0,
        costCenter1: null as number | null,
        costCenter2: null as number | null,
        taxCode: null as string | null,
    },
];
type Input = {
    reference: string;
    customer: string;
    service: string;
    quantity: number;
    receiptDate: string;
    dueDate: string;
};
type Job = {
    schema: 1;
    reference: string;
    input: Input;
    payload: any;
    currency: string;
    state: 'reserved' | 'created' | 'verified';
    receiptId: string | null;
    createdAt: string;
    updatedAt: string;
};

function id(value: unknown) {
    const result = String(value);
    if (!/^[a-f0-9]{24}$/i.test(result)) throw new Error('Configure a valid full mandate or receipt ID.');
    return result;
}
function reference(value: unknown) {
    if (typeof value !== 'string' || !/^[A-Z0-9][A-Z0-9_-]{2,59}$/.test(value))
        throw new Error(
            'Use a stable 3–60 character usage reference: capital letters, numbers, underscores or hyphens.',
        );
    return value;
}
function day(value: unknown) {
    if (
        typeof value !== 'string' ||
        !/^20\d\d-\d{2}-\d{2}$/.test(value) ||
        !Number.isFinite(Date.parse(`${value}T00:00:00Z`)) ||
        new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value
    )
        throw new Error('Use a real calendar date from 2000 to 2099.');
    return value;
}
function key(ref: string): [string, string, string] {
    return ['usage-invoice-drafts', id(MANDATE_ID), reference(ref)];
}
function prepare(raw: unknown) {
    if (!raw || typeof raw !== 'object') throw new Error('Enter a usage record.');
    const value = raw as Record<string, unknown>;
    const customer = CUSTOMERS.find((item) => item.key === value.customer);
    const service = SERVICES.find((item) => item.key === value.service);
    if (!customer || !service) throw new Error('Choose a configured customer and service.');
    if (
        !Number.isSafeInteger(customer.account) ||
        customer.account <= 0 ||
        !Number.isSafeInteger(service.account) ||
        service.account <= 0
    )
        throw new Error('Configure the debtor account and revenue account before previewing.');
    if (!Number.isSafeInteger(service.unitAmount) || service.unitAmount <= 0)
        throw new Error('Configure an integer minor-unit price greater than zero.');
    const quantity = Number(value.quantity);
    if (
        typeof value.quantity !== 'number' ||
        !Number.isSafeInteger(quantity) ||
        quantity < 1 ||
        quantity > 10000
    )
        throw new Error('Quantity must be a whole number from 1 to 10,000.');
    const input: Input = {
        reference: reference(value.reference),
        customer: customer.key,
        service: service.key,
        quantity,
        receiptDate: day(value.receiptDate),
        dueDate: day(value.dueDate),
    };
    if (input.dueDate < input.receiptDate) throw new Error('Due date must not precede the invoice date.');
    const total = quantity * service.unitAmount;
    if (!Number.isSafeInteger(total) || total > 100000000)
        throw new Error('Split this usage record into smaller reviewed jobs.');
    if (!/^[A-Z]{3}$/.test(CURRENCY)) throw new Error('Configure the mandate currency.');
    const payload = {
        mandateId: id(MANDATE_ID),
        account: customer.account,
        accountName: customer.name,
        address: customer.address,
        deliveryDateType: null,
        title: `Usage ${input.reference}`,
        intro: '',
        note: `Developer usage reference: ${input.reference}`,
        receiptDate: `${input.receiptDate}T00:00:00.000Z`,
        dueDate: `${input.dueDate}T00:00:00.000Z`,
        positions: [
            {
                unitAmount: service.unitAmount,
                quantity,
                discount: 0,
                description: service.name,
                unit: service.unit,
                account: service.account,
                costCenter1: service.costCenter1,
                costCenter2: service.costCenter2,
                taxCode: service.taxCode,
            },
        ],
        isNet: false,
        draft: true,
        sendMethod: 'none' as const,
        email: '',
        doNotSendReceipt: true,
        useDepositReceipts: false,
        paymentMethod: null,
        queueReceiptDocument: false,
    };
    return {
        input,
        payload,
        reviewToken: JSON.stringify(payload),
        total,
        currency: CURRENCY,
        customer: customer.name,
        service: service.name,
    };
}
function checkedJob(value: unknown, ref: string): Job {
    const job = value as Job;
    if (
        !job ||
        job.schema !== 1 ||
        job.reference !== ref ||
        !job.payload ||
        !['reserved', 'created', 'verified'].includes(job.state) ||
        job.payload.mandateId !== MANDATE_ID ||
        job.payload.note !== `Developer usage reference: ${ref}` ||
        job.payload.draft !== true ||
        job.payload.doNotSendReceipt !== true ||
        job.payload.sendMethod !== 'none'
    )
        throw new Error('Unsupported stored job. Preserve it for review; no invoice was created.');
    return job;
}
function dateIso(value: unknown) {
    return value instanceof Date ? value.toISOString() : value;
}
function verifyReceipt(receipt: any, job: Job) {
    const expected = job.payload;
    const actual = receipt?.positions?.[0];
    const position = expected.positions[0];
    if (
        !receipt ||
        receipt.type !== 'invoice' ||
        receipt.draft !== true ||
        String(receipt.mandate) !== MANDATE_ID ||
        receipt.account !== expected.account ||
        receipt.accountName !== expected.accountName ||
        receipt.title !== expected.title ||
        receipt.note !== expected.note ||
        receipt.sendMethod !== 'none' ||
        receipt.doNotSendReceipt !== true ||
        receipt.isNet !== false ||
        receipt.canceledAt ||
        receipt.archivedAt ||
        receipt.payments?.length ||
        dateIso(receipt.receiptDate) !== expected.receiptDate ||
        dateIso(receipt.dueDate) !== expected.dueDate ||
        !Array.isArray(receipt.positions) ||
        receipt.positions.length !== 1 ||
        [
            'unitAmount',
            'quantity',
            'discount',
            'description',
            'unit',
            'account',
            'costCenter1',
            'costCenter2',
            'taxCode',
        ].some((field) => actual?.[field] !== position[field]) ||
        ['country', 'zip', 'city', 'addressLine'].some(
            (field) => receipt.address?.[field] !== expected.address[field],
        ) ||
        receipt.totalGrossAmount !== position.unitAmount * position.quantity
    )
        throw new Error(
            'The receipt does not match the reserved draft. Inspect it in campai; no create was repeated.',
        );
}
export async function loadDesk() {
    const jobs: Job[] = [];
    for await (const entry of storage.list<Job>(
        { prefix: ['usage-invoice-drafts', id(MANDATE_ID)] },
        { limit: 101 },
    )) {
        const ref = reference(entry.key[2]);
        jobs.push(checkedJob(entry.value, ref));
    }
    if (jobs.length > 100)
        throw new Error('This small desk lists at most 100 jobs. Add pagination before continuing.');
    return {
        enabled: ALLOW_DRAFTS,
        currency: CURRENCY,
        customers: CUSTOMERS.map(({ key, name }) => ({ key, name })),
        services: SERVICES.map(({ key, name, unitAmount, unit }) => ({ key, name, unitAmount, unit })),
        jobs: jobs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
    };
}
export async function previewUsage(raw: unknown) {
    return prepare(raw);
}
export async function createDraft(raw: unknown) {
    if (!ALLOW_DRAFTS) throw new Error('Draft creation is disabled in the server configuration.');
    if (!raw || typeof raw !== 'object') throw new Error('Preview the usage record first.');
    const request = raw as Record<string, unknown>;
    const prepared = prepare(request.input);
    if (request.reviewToken !== prepared.reviewToken)
        throw new Error('The configured invoice changed. Preview and review it again.');
    if ((await loadDesk()).jobs.length >= 100)
        throw new Error('Add pagination and a retention workflow before creating more jobs in this example.');
    const jobKey = key(prepared.input.reference);
    const existing = await storage.get<Job>(jobKey);
    if (existing.versionstamp !== null)
        throw new Error(
            'This usage reference is already reserved. Review its saved outcome; do not create again.',
        );
    const now = new Date().toISOString();
    const job: Job = {
        schema: 1,
        reference: prepared.input.reference,
        input: prepared.input,
        payload: prepared.payload,
        currency: CURRENCY,
        state: 'reserved',
        receiptId: null,
        createdAt: now,
        updatedAt: now,
    };
    // Reserve durably before the API request. A failed/uncertain reservation never sends a request.
    const reservation = await storage.atomic().check(existing).set(jobKey, job).commit();
    if (!reservation.ok) throw new Error('Another request reserved this usage reference. Reload the desk.');
    let receiptId: string;
    try {
        const created = await api.finance.receipts.createInvoiceReceipt(prepared.payload);
        receiptId = id(created._id);
    } catch {
        // Even a rejected response stays reserved: no automated retries after a possibly successful write.
        throw new Error(
            'Creation was not confirmed. The reference stays reserved. Reload, inspect campai, and reconcile any matching draft.',
        );
    }
    job.receiptId = receiptId;
    job.state = 'created';
    job.updatedAt = new Date().toISOString();
    try {
        const saved = await storage
            .atomic()
            .check({ key: jobKey, versionstamp: reservation.versionstamp })
            .set(jobKey, job)
            .commit();
        if (!saved.ok) throw new Error('Job changed.');
    } catch {
        throw new Error(
            `Draft ${receiptId} was created, but saving its ID was not confirmed. Record this ID and reconcile the reserved job. Do not create again.`,
        );
    }
    return reconcileDraft({ reference: job.reference, receiptId });
}
export async function reconcileDraft(raw: unknown) {
    if (!raw || typeof raw !== 'object') throw new Error('Choose a saved job and its receipt ID.');
    const value = raw as Record<string, unknown>;
    const ref = reference(value.reference);
    const receiptId = id(value.receiptId);
    const entry = await storage.get<Job>(key(ref));
    if (entry.versionstamp === null) throw new Error('No reserved job exists for this usage reference.');
    const job = checkedJob(entry.value, ref);
    if (job.receiptId && job.receiptId !== receiptId)
        throw new Error('Use the receipt ID already recorded for this job.');
    if (job.state === 'reserved' && Date.now() - Date.parse(job.createdAt) < 120000)
        throw new Error('Creation may still be running. Wait two minutes before reconciling a reserved job.');
    const receipt = await api.finance.receipts.getReceipt({ mandateId: id(MANDATE_ID), receiptId });
    verifyReceipt(receipt, job);
    job.state = 'verified';
    job.receiptId = receiptId;
    job.updatedAt = new Date().toISOString();
    const saved = await storage.atomic().check(entry).set(entry.key, job).commit();
    if (!saved.ok)
        throw new Error(
            'The saved job changed during verification. Reload and reconcile again; no invoice was created.',
        );
    return { reference: ref, receiptId, state: 'verified' as const };
}

Check your result

Preview three units at 1500 minor units
The gross total is 4500; previewing creates no invoice.
Submit the same reference again
The reservation blocks a duplicate create.
Reload after failed read-back
The saved receipt ID remains available for verification.
Verify an existing draft
Its saved values are checked without another create request.
Continue with Publish and share an app