Skip to content
campaiDeveloper
campai.com

Your club sells shirts or equipment through a shop. The shop collects orders, while the treasurer wants campai to hold the invoice drafts. Build a small import desk that shows the customer, line items and accounting allocation before creating each draft once.

Use this external API integration when the order originates outside campai. A Developer App is a better fit when staff enter and review usage inside campai; Turn confirmed usage into invoice drafts covers that workflow.

This recipe supports orders that are open, unpaid and not already invoiced. Agree which system owns invoicing before connecting them. It does not import a shop’s already issued invoice, record an existing payment, issue the new draft, or initiate collection.

What you’ll have at the end

A private import desk that turns reviewed, unpaid shop orders into verified unsent invoice drafts.

  1. Shop order export
  2. Customer and SKU mapping
  3. Operator review and reservation
  4. Unsent campai draft and read-back
Before you start
  • Node.js 22+ and a private local directory
  • A synthetic debtor and reviewed SKU/accounting mappings
  • Mandates Read, Debtors Read and Invoices and receipts Read/Insert
On this page

Choose a designated test customer with an existing debtor account. Use synthetic details and no real payment method. Ask the treasurer for the appropriate revenue account, tax code and cost centers for the test items and shipping. Some accounting plans require a cost-center region even for a draft.

The example imports a documented JSON contract. You can later map that contract to any shop’s authenticated API or export. The two supplied records use stable IDs, two different order references, and explicit line totals. They are not tied to a particular shop vendor.

Your adapter must know whether an order is already invoiced or paid. Do not assume that “completed,” “processing” or another vendor-specific status means unpaid. Read the provider’s actual payment and invoice state and map it deliberately.

Download the complete example below. It contains server.mjs, orders.example.json and installation notes. Use Node.js 22+ in a private directory outside your web root; no packages are required.

Set the full organization and mandate IDs, a stable shopId, and the actual currency. Keep allowDrafts set to false initially. Configure these mappings:

Mapping What to verify
Shop customer ID The exact existing debtor account, name and billing address.
SKU The product or shipping service this line represents.
Gross unit price Integer minor units, matching the order exactly.
Revenue account The treasurer’s accounting allocation for this SKU.
Tax code The applicable configured code; null sends no code.
Cost centers Any required allocation for this mandate.

Account 0 is an invalid placeholder. Do not choose arbitrary accounts to bypass validation. A debtor account and a revenue account are different identifiers; neither is a contact ID. Read Understand money, dates, and data types before adapting amounts or dates.

Run node server.mjs and leave the terminal running. Open http://127.0.0.1:9371/.

Choose orders.example.json under Order JSON file, then select Import orders. Both records should appear in the queue. The import panel closes after success so the orders stay in view; open it again to reimport a file. Importing validates the entire file before changing the queue and does not call campai.

Importing validates the orders without creating invoices.

Importing validates the orders without creating invoices.

Two unpaid shop orders in the review queue before invoice creation

Select Review order for ORDER-001. Its two shirts at 2000 minor units each plus shipping at 500 total 4500: 45.00 in the configured currency. Check the recipient, billing address, debtor account, dates, every line’s allocation, and total.

Review each order separately; ORDER-002 totals 25.00.

Review each order separately; ORDER-002 totals 25.00.

The second order review showing its customer, shirt, shipping and 25-euro total beside the first verified draft

Shipping must be a separate mapped line. The example rejects hidden fees, discounts, refunds, extra fields, duplicate SKUs, fractional quantities, currency mismatches and totals that do not add up. Extend and test the mapping explicitly if your shop needs those cases.

Reimporting the same record leaves its saved status unchanged. A changed pending order can replace its prior input, invalidating an old preview. Once an order is reserved, changed input is rejected for investigation.

Create a temporary key restricted to the test mandate with Mandates Read, Debtors Read, and Invoices and receipts Read/Insert. Follow API permissions.

Stop the local server with Ctrl+C. Add the key to its private configuration, set allowDrafts to true, and restart in the same directory. Keep shop-order-state.json; it holds the source-order reservations.

Review ORDER-001 again. Check its current state in the shop and confirm that no other system has invoiced it or collected payment. Select Create this draft once.

The server reads the exact configured debtor and checks its account, name, address, mandate and blocked state. It then saves a reservation before the create request, stores the returned receipt ID, and reads that receipt back. Verification compares the unsent draft state, customer, dates, every position and allocation, payments, deposit usage and gross total.

Inspect the same unsent draft in campai.

Inspect the same unsent draft in campai.

The saved shop invoice recipient, order title, dates and No sending setting
The invoice keeps the reviewed line quantities, amounts and allocation.

The invoice keeps the reviewed line quantities, amounts and allocation.

Two shirt units and shipping on the saved campai draft, totaling 45 euros

In campai, open Payments → Invoices and donations. Change the All receipts selector to Draft, locate the designated customer and amount, and click Drafted receipt. The Edit Invoice dialog opens. Compare the source order in its title, the reservation note, recipient, No sending, positions, dates and total. Close with Cancel after inspection. Create Invoice belongs to your separate issuing workflow.

The example requests no payment, deposit allocation, sending, or finalization. The shop’s export remains an operator-verified source; the local checkbox does not contact the shop or automatically detect later changes there.

The underlying request uses POST /{organizationId}/{mandateId}/receipts/invoice. Read-back uses GET /{organizationId}/{mandateId}/finance/receipts/{receiptId}. Keep the returned full ID with the source-order reservation.

Example

curl --fail-with-body --silent --show-error --max-time 30 --globoff \
  --request POST 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/receipts/invoice' \
  --header 'X-API-Key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "account": 100001,
  "accountName": "Example club-shop customer",
  "address": {
    "country": "DE",
    "zip": "10115",
    "city": "Berlin",
    "addressLine": "Example street 1"
  },
  "deliveryDateType": null,
  "title": "Shop club-shop / YOUR_ORDER_ID",
  "intro": "",
  "note": "Shop order reservation: YOUR_DURABLE_RESERVATION",
  "receiptDate": "2026-11-05T00:00:00.000Z",
  "dueDate": "2026-11-19T00:00:00.000Z",
  "positions": [
    {
      "unitAmount": 2000,
      "quantity": 2,
      "discount": 0,
      "description": "Club shirt",
      "unit": "piece",
      "account": 40000,
      "costCenter1": null,
      "costCenter2": null,
      "taxCode": null
    },
    {
      "unitAmount": 500,
      "quantity": 1,
      "discount": 0,
      "description": "Shipping",
      "unit": "shipment",
      "account": 40000,
      "costCenter1": null,
      "costCenter2": null,
      "taxCode": null
    }
  ],
  "isNet": false,
  "draft": true,
  "sendMethod": "none",
  "email": "",
  "doNotSendReceipt": true,
  "useDepositReceipts": false,
  "paymentMethod": null,
  "queueReceiptDocument": false
}'

These diagnostic requests create a draft. Configure a disposable debtor, valid accounting allocation and a unique reservation reference before running one. The complete server adds the durable review and reconciliation workflow; a single HTTP request is not duplicate protection.

Review ORDER-002, select Test reconciliation after an interrupted result, confirm the source, and create the draft. The server saves its returned ID and deliberately stops before read-back. The order remains Created · verify.

The reservation remains until the existing draft is verified.

The reservation remains until the existing draft is verified.

A saved invoice ID awaiting verification after an interrupted result

Select Verify existing draft. The same receipt is read and compared with the reservation; no create is repeated. The order becomes Verified draft.

Reimporting the same orders leaves both saved results unchanged.

Reimporting the same orders leaves both saved results unchanged.

Two verified drafts and an unchanged reimport result

A real lost create response can leave Reserved · inspect, with no known receipt ID. Wait at least two minutes for the original request to finish, then inspect campai for the exact source order title and reservation note. Confirm one matching draft, copy its full ID, and verify it. No match or several matches require investigation. Do not change the source reference or delete the ledger to make another create possible.

Check Expected result
Import the same order twice One saved source-order record.
Create the same reviewed order from two views At most one create; the other review is stale or reserved.
Change an already reserved order Reject the import and preserve its saved outcome.
Change a pending order’s quantity and total Require a new review.
Change a price without updating the approved catalog Block preview.
Revoke the key before a new create Fail the debtor preflight; create no draft.
Lose the create response Preserve the reservation and reconcile the existing receipt.
Read back a different allocation or a paid receipt Keep the result unresolved.

Revoke the key and remove the exact synthetic drafts and debtor through campai’s normal workflow after testing. Stop the local server and remove only the exercise’s private files.

Map your provider’s stable shop/order/customer/SKU identifiers into this contract. Keep all charged lines explicit and calculate the exact gross total from its original integer values. An email address is not a reliable customer key. Maintain a reviewed link to an existing debtor; this example does not create customer accounts.

For unattended use, read the current order through the shop’s authenticated API immediately before reservation and coordinate invoice ownership with its own workflow. A webhook can trigger that read, but its old payload may no longer describe the current payment state. Test cancellations, refunds, partial payments, discounts, shipping, currency differences and concurrent updates before supporting them.

The local ledger protects this one desk. Another directory or integration has a separate ledger, and the shop’s invoice system cannot see it. Use shared transactional reservations, authenticated staff access, secret management, audit identities, monitoring and a reviewed recovery procedure before running multiple workers. Preserve unresolved outcomes across restarts and deployments.

The complete shop-order review desk

Configure the private key, debtor mapping and accounting allocation. Import the supplied order contract, review each order and create unsent drafts. Creation starts disabled.

Download ZIP
View and copy the complete source

The complete shop-order review desk

import { createServer } from 'node:http';
import { randomBytes, createHash } from 'node:crypto';
import { open, readFile, rename, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
// Private local review desk. Adapt the import contract to your shop on your backend.
const apiKey = 'YOUR_API_KEY';
const organization = 'YOUR_ORGANIZATION_ID';
const mandate = 'YOUR_MANDATE_ID';
const shopId = 'club-shop';
const currency = 'EUR'; // Actual accounting currency; never relabel or convert an order.
const allowDrafts = false;
const port = 9371;
const apiBase = 'https://cloud.campai.com/api';
const customers = [{
        id: 'shop-customer-1', account: 0, name: 'Example club-shop customer',
        address: { country: 'DE', zip: '10115', city: 'Berlin', addressLine: 'Example street 1' },
    }];
const products = [
    { sku: 'CLUB-SHIRT', description: 'Club shirt', unit: 'piece', unitGrossAmount: 2000, account: 0, costCenter1: null, costCenter2: null, taxCode: null },
    { sku: 'SHIPPING', description: 'Shipping', unit: 'shipment', unitGrossAmount: 500, account: 0, costCenter1: null, costCenter2: null, taxCode: null },
];
const origin = `http://127.0.0.1:${port}`;
const scope = { organization, mandate, shopId };
const statePath = resolve('shop-order-state.json');
const csrf = randomBytes(24).toString('hex');
const hash = value => createHash('sha256').update(value).digest('hex');
const id = value => typeof value === 'string' && /^[a-f0-9]{24}$/i.test(value);
const fail = (message, status = 400) => Object.assign(new Error(message), { status });
const reference = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/.test(value);
if (!id(organization) || !id(mandate) || !reference(shopId) || !/^[A-Z]{3}$/.test(currency))
    throw Error('Configure the full account scope, shop ID and actual currency.');
if (allowDrafts && apiKey === 'YOUR_API_KEY')
    throw Error('Configure the private key before enabling drafts.');
function keys(value, expected) {
    if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !expected.includes(key)))
        throw fail('Unexpected fields. Map the complete shop order to the documented contract first.');
}
function day(value) {
    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 fail('Use real YYYY-MM-DD invoice and due dates.');
    return value;
}
function normalize(raw) {
    keys(raw, ['orderId', 'customerId', 'currency', 'status', 'paymentStatus', 'invoiced', 'receiptDate', 'dueDate', 'totalGrossAmount', 'lines']);
    if (!reference(raw.orderId) || !reference(raw.customerId))
        throw fail('Use stable shop order and customer IDs.');
    if (raw.currency !== currency || raw.status !== 'open' || raw.paymentStatus !== 'unpaid' || raw.invoiced !== false)
        throw fail('Only open, unpaid, uninvoiced orders in the configured currency are supported.');
    if (!Array.isArray(raw.lines) || raw.lines.length < 1 || raw.lines.length > 20)
        throw fail('Use 1–20 explicit order lines, including shipping where needed.');
    const seen = new Set(), lines = [];
    for (const line of raw.lines) {
        keys(line, ['sku', 'quantity', 'unitGrossAmount']);
        if (!reference(line.sku) || seen.has(line.sku) || !Number.isSafeInteger(line.quantity) || line.quantity < 1 || line.quantity > 10000 || !Number.isSafeInteger(line.unitGrossAmount) || line.unitGrossAmount <= 0)
            throw fail('Use unique SKUs, whole positive quantities and positive integer minor-unit prices.');
        seen.add(line.sku);
        lines.push({ sku: line.sku, quantity: line.quantity, unitGrossAmount: line.unitGrossAmount });
    }
    lines.sort((a, b) => a.sku < b.sku ? -1 : a.sku > b.sku ? 1 : 0);
    const total = lines.reduce((sum, line) => sum + line.quantity * line.unitGrossAmount, 0);
    if (!Number.isSafeInteger(total) || total > 100000000 || raw.totalGrossAmount !== total)
        throw fail('Order total must exactly equal all gross lines. Discounts, refunds and hidden fees need a separate mapping.');
    const receiptDate = day(raw.receiptDate), dueDate = day(raw.dueDate);
    if (dueDate < receiptDate)
        throw fail('Due date must not precede invoice date.');
    return { orderId: raw.orderId, customerId: raw.customerId, currency, status: 'open', paymentStatus: 'unpaid', invoiced: false, receiptDate, dueDate, totalGrossAmount: total, lines };
}
function prepare(input) {
    const customer = customers.filter(c => c.id === input.customerId);
    if (customer.length !== 1)
        throw fail('Map this exact shop customer to one reviewed debtor.');
    const c = customer[0];
    if (!Number.isSafeInteger(c.account) || c.account <= 0 || typeof c.name !== 'string' || !c.name.trim() || !c.address || !['country', 'zip', 'city', 'addressLine'].every(k => typeof c.address[k] === 'string' && c.address[k].trim()))
        throw fail('Configure the customer account, name and billing address before previewing.');
    const positions = input.lines.map(line => {
        const matched = products.filter(p => p.sku === line.sku);
        if (matched.length !== 1)
            throw fail('Map every SKU, including shipping, to one reviewed accounting allocation.');
        const p = matched[0];
        if (p.unitGrossAmount !== line.unitGrossAmount || !Number.isSafeInteger(p.account) || p.account <= 0 || !['costCenter1', 'costCenter2'].every(k => p[k] === null || (Number.isSafeInteger(p[k]) && p[k] >= 0)) || (p.taxCode !== null && (typeof p.taxCode !== 'string' || !p.taxCode)))
            throw fail('The order price or accounting allocation does not match the reviewed catalog.');
        if (typeof p.description !== 'string' || !p.description.trim() || typeof p.unit !== 'string' || !p.unit.trim())
            throw fail('Configure the product description and unit.');
        return { unitAmount: line.unitGrossAmount, quantity: line.quantity, discount: 0, description: p.description, unit: p.unit, account: p.account, costCenter1: p.costCenter1, costCenter2: p.costCenter2, taxCode: p.taxCode };
    });
    const jobKey = hash(JSON.stringify([organization, mandate, shopId, input.orderId]));
    const payload = { account: c.account, accountName: c.name, address: c.address, deliveryDateType: null, title: `Shop ${shopId} / ${input.orderId}`, intro: '', note: `Shop order reservation: ${jobKey}`, receiptDate: input.receiptDate + 'T00:00:00.000Z', dueDate: input.dueDate + 'T00:00:00.000Z', positions, isNet: false, draft: true, sendMethod: 'none', email: '', doNotSendReceipt: true, useDepositReceipts: false, paymentMethod: null, queueReceiptDocument: false };
    return { payload, jobKey, reviewToken: hash(JSON.stringify({ scope, input, payload })), total: input.totalGrossAmount, currency };
}
const processLock = await open(statePath + '.lock', 'wx', 0o600);
let state;
try {
    state = JSON.parse(await readFile(statePath, 'utf8'));
    if (state.version !== 1 || JSON.stringify(state.scope) !== JSON.stringify(scope) || !Number.isSafeInteger(state.revision) || state.revision < 0 || !Array.isArray(state.orders) || state.orders.length > 200)
        throw Error('State or scope mismatch. Preserve the saved ledger.');
    const seen = new Set();
    for (const row of state.orders) {
        const input = normalize(row.input);
        if (JSON.stringify(input) !== JSON.stringify(row.input) || row.inputHash !== hash(JSON.stringify(input)) || row.key !== hash(JSON.stringify([organization, mandate, shopId, input.orderId])) || seen.has(row.key) || !['pending', 'reserved', 'created', 'verified'].includes(row.status) || (row.receiptId !== null && !id(row.receiptId)))
            throw Error('Invalid saved order; preserve the ledger for investigation.');
        if (row.status !== 'pending' && (!row.payload || row.payloadHash !== hash(JSON.stringify(row.payload)) || row.payload.note !== `Shop order reservation: ${row.key}` || row.payload.draft !== true || row.payload.sendMethod !== 'none' || row.payload.doNotSendReceipt !== true))
            throw Error('Invalid saved draft reservation.');
        if (['created', 'verified'].includes(row.status) && !row.receiptId)
            throw Error('Saved draft ID is missing.');
        seen.add(row.key);
    }
}
catch (error) {
    if (error.code !== 'ENOENT') {
        await processLock.close();
        await unlink(statePath + '.lock');
        throw error;
    }
    state = { version: 1, scope, revision: 0, orders: [] };
}
let pending = Promise.resolve();
function exclusive(action) { const result = pending.then(action); pending = result.catch(() => { }); return result; }
async function save(next) { next.revision = state.revision + 1; const path = statePath + '.' + process.pid + '.tmp', file = await open(path, 'wx', 0o600); try {
    await file.writeFile(JSON.stringify(next, null, 2));
    await file.sync();
}
finally {
    await file.close();
} await rename(path, statePath); state = next; }
async function api(path, body) {
    const response = await fetch(`${apiBase}/${organization}/${mandate}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: { 'X-API-Key': apiKey, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'error', signal: AbortSignal.timeout(15000) });
    if (!response.ok)
        throw Error(`campai HTTP ${response.status}`);
    if (!response.body)
        throw Error('Empty API body.');
    const reader = response.body.getReader(), chunks = [];
    let size = 0;
    try {
        while (true) {
            const { done, value } = await reader.read();
            if (done)
                break;
            size += value.length;
            if (size > 2 * 1024 * 1024) {
                await reader.cancel();
                throw Error('API response too large.');
            }
            chunks.push(value);
        }
    }
    finally {
        reader.releaseLock();
    }
    return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
async function verifyCustomer(payload) { const debtor = await api('/finance/accounts/debtors/' + payload.account); if (!id(debtor?._id) || !debtor.mandates?.includes(mandate) || debtor.account !== payload.account || debtor.name !== payload.accountName || debtor.blockedAt !== null || ['country', 'zip', 'city', 'addressLine'].some(k => debtor.address?.[k] !== payload.address[k]))
    throw Error('The debtor no longer matches the reviewed customer or is blocked.'); }
function verifyReceipt(receipt, row, receiptId) {
    const p = row.payload;
    if (!receipt || receipt._id !== receiptId || receipt.type !== 'invoice' || receipt.mandate !== mandate || receipt.draft !== true || receipt.account !== p.account || receipt.accountName !== p.accountName || receipt.title !== p.title || receipt.note !== p.note || receipt.sendMethod !== 'none' || receipt.doNotSendReceipt !== true || receipt.isNet !== false || receipt.refund !== false || receipt.canceledAt !== null || receipt.archivedAt !== null || !Array.isArray(receipt.payments) || receipt.payments.length !== 0 || receipt.paymentMethod !== null || !Array.isArray(receipt.depositUsages) || receipt.depositUsages.length !== 0 || receipt.receiptDate !== p.receiptDate || receipt.dueDate !== p.dueDate || receipt.totalGrossAmount !== row.input.totalGrossAmount || !Array.isArray(receipt.positions) || receipt.positions.length !== p.positions.length || ['country', 'zip', 'city', 'addressLine'].some(k => receipt.address?.[k] !== p.address[k]))
        throw Error('The saved receipt does not match the reserved unsent draft.');
    for (let i = 0; i < p.positions.length; i++)
        if (['unitAmount', 'quantity', 'discount', 'description', 'unit', 'account', 'costCenter1', 'costCenter2', 'taxCode'].some(k => receipt.positions[i]?.[k] !== p.positions[i][k]))
            throw Error('A saved invoice line differs from the reviewed order.');
}
function view(row) { return { key: row.key, input: row.input, status: row.status, receiptId: row.receiptId, importedAt: row.importedAt, reservedAt: row.reservedAt, error: row.error }; }
function json(res, status, value) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(value)); }
async function body(req) { if (req.headers['content-type']?.split(';')[0] !== 'application/json')
    throw fail('Use JSON.', 415); let size = 0; const chunks = []; for await (const chunk of req) {
    size += chunk.length;
    if (size > 128000)
        throw fail('Import at most 128 KB.', 413);
    chunks.push(chunk);
} try {
    return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
catch {
    throw fail('Invalid JSON.');
} }
const server = createServer(async (req, res) => {
    res.setHeader('Cache-Control', 'no-store');
    res.setHeader('X-Robots-Tag', 'noindex, nofollow');
    res.setHeader('X-Content-Type-Options', 'nosniff');
    res.setHeader('Referrer-Policy', 'no-referrer');
    try {
        if (req.headers.host !== `127.0.0.1:${port}`)
            throw fail('Unexpected host.', 403);
        const path = new URL(req.url, origin).pathname;
        if (req.method === 'GET' && path === '/') {
            const nonce = randomBytes(24).toString('hex');
            res.setHeader('Content-Security-Policy', `default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`);
            res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
            res.end(page(nonce));
            return;
        }
        if (req.method === 'GET' && path === '/api/orders') {
            json(res, 200, { revision: state.revision, allowDrafts, currency, orders: state.orders.map(view) });
            return;
        }
        if (req.method !== 'POST')
            throw fail('Not found.', 404);
        if (req.headers.origin !== origin || req.headers['x-csrf-token'] !== csrf)
            throw fail('Refresh this local page before continuing.', 403);
        const input = await body(req);
        const result = await exclusive(async () => {
            if (path === '/api/import') {
                keys(input, ['schema', 'shopId', 'orders']);
                if (input.schema !== 1 || input.shopId !== shopId || !Array.isArray(input.orders) || input.orders.length < 1 || input.orders.length > 25)
                    throw fail('Import 1–25 orders for the configured shop.');
                const inputs = input.orders.map(normalize), seen = new Set(), next = structuredClone(state);
                let added = 0, updated = 0, unchanged = 0;
                for (const order of inputs) {
                    const key = hash(JSON.stringify([organization, mandate, shopId, order.orderId]));
                    if (seen.has(key))
                        throw fail('Duplicate order ID in this file.');
                    seen.add(key);
                    const inputHash = hash(JSON.stringify(order)), old = next.orders.find(r => r.key === key);
                    if (old) {
                        if (old.inputHash === inputHash) {
                            unchanged++;
                            continue;
                        }
                        if (old.status !== 'pending')
                            throw fail('An imported order changed after reservation. Investigate without creating again.', 409);
                        old.input = order;
                        old.inputHash = inputHash;
                        old.importedAt = new Date().toISOString();
                        updated++;
                    }
                    else {
                        next.orders.push({ key, input: order, inputHash, status: 'pending', receiptId: null, payload: null, payloadHash: null, importedAt: new Date().toISOString(), reservedAt: null, error: null });
                        added++;
                    }
                }
                if (next.orders.length > 200)
                    throw fail('This exercise holds at most 200 orders. Add a retention workflow before growing it.');
                if (added || updated)
                    await save(next);
                return { added, updated, unchanged, revision: state.revision };
            }
            if (input.revision !== state.revision)
                throw fail('The desk changed. Reload and review again.', 409);
            const row = state.orders.find(r => r.key === input.key);
            if (!row)
                throw fail('Order not found.', 404);
            if (path === '/api/preview') {
                if (row.status !== 'pending')
                    throw fail('This order is already reserved. Review its saved result.', 409);
                return { ...prepare(row.input), revision: state.revision, order: view(row) };
            }
            if (path === '/api/reconcile') {
                if (!['reserved', 'created'].includes(row.status))
                    throw fail('Only unresolved reservations can be reconciled.', 409);
                const receiptId = row.receiptId ?? input.receiptId;
                if (!id(receiptId) || (row.receiptId && input.receiptId && row.receiptId !== input.receiptId))
                    throw fail('Use the saved ID or the exact matching draft found in campai.');
                if (row.status === 'reserved' && Date.now() - Date.parse(row.reservedAt) < 120000)
                    throw fail('Wait two minutes; the original create may still be running.', 409);
                const receipt = await api('/finance/receipts/' + receiptId);
                verifyReceipt(receipt, row, receiptId);
                const next = structuredClone(state), target = next.orders.find(r => r.key === row.key);
                target.receiptId = receiptId;
                target.status = 'verified';
                target.error = null;
                await save(next);
                return { order: view(target) };
            }
            if (path !== '/api/create')
                throw fail('Not found.', 404);
            if (!allowDrafts)
                throw fail('Draft creation is disabled in the private configuration.', 403);
            if (row.status !== 'pending')
                throw fail('This order is already reserved. Never create again from a lost response.', 409);
            const prepared = prepare(row.input);
            if (input.reviewToken !== prepared.reviewToken || input.confirmedSource !== true)
                throw fail('Review the current order and confirm that the shop has not invoiced or collected payment.', 409);
            await verifyCustomer(prepared.payload);
            let next = structuredClone(state), target = next.orders.find(r => r.key === row.key);
            target.status = 'reserved';
            target.reservedAt = new Date().toISOString();
            target.payload = prepared.payload;
            target.payloadHash = hash(JSON.stringify(prepared.payload));
            await save(next);
            try {
                const created = await api('/receipts/invoice', prepared.payload);
                if (!id(created?._id))
                    throw Error('No valid draft ID returned.');
                next = structuredClone(state);
                target = next.orders.find(r => r.key === row.key);
                target.receiptId = created._id;
                target.status = 'created';
                await save(next);
                if (input.simulateLostResult === true)
                    throw Error('Simulated interruption after saving the draft ID. Reconcile the existing draft.');
                const receipt = await api('/finance/receipts/' + created._id);
                verifyReceipt(receipt, target, created._id);
                next = structuredClone(state);
                target = next.orders.find(r => r.key === row.key);
                target.status = 'verified';
                target.error = null;
                await save(next);
            }
            catch (error) {
                next = structuredClone(state);
                target = next.orders.find(r => r.key === row.key);
                target.error = String(error.message).slice(0, 200);
                await save(next);
            }
            return { order: view(target) };
        });
        json(res, 200, result);
    }
    catch (error) {
        json(res, error.status ?? 502, { error: error.status ? error.message : 'The operation could not be verified. Keep the reservation and inspect the source before another action.' });
    }
});
server.requestTimeout = 20000;
server.headersTimeout = 15000;
server.on('error', async (error) => { console.error('Local server could not start:', error.code ?? 'unknown'); await processLock.close(); await unlink(statePath + '.lock'); process.exitCode = 1; });
server.listen(port, '127.0.0.1', () => console.log(`Shop desk: ${origin}/\nDraft creation ${allowDrafts ? 'enabled' : 'disabled'}. Keep this exercise private.`));
let stopping = false;
function stop() { if (stopping)
    return; stopping = true; server.close(async () => { await pending; await processLock.close(); await unlink(statePath + '.lock'); }); }
process.on('SIGINT', stop);
process.on('SIGTERM', stop);

// Embedded interface. Colors are resolved from the standard Tailwind palette.
const styles = `
summary { cursor: pointer; font-weight: 650; font-size: 1.125rem; }
details[open] > summary { margin-bottom: 1.25rem; }
:root {
  --color-zinc-50: oklch(98.5% 0 none);
  --color-zinc-950: oklch(14.1% 0.005 285.823);
  --color-zinc-600: oklch(44.2% 0.017 285.786);
  --color-amber-900: oklch(41.4% 0.112 45.904);
  --color-white: #fff;
  --color-zinc-200: oklch(92% 0.004 286.32);
  --color-amber-50: oklch(98.7% 0.022 95.277);
  --color-amber-100: oklch(96.2% 0.059 95.617);
  --color-amber-500: oklch(76.9% 0.188 70.08);
  --color-amber-800: oklch(47.3% 0.137 46.201);
  --color-zinc-500: oklch(55.2% 0.016 285.938);
  --color-black: #000;
  --color-emerald-50: oklch(97.9% 0.021 166.113);
  --color-emerald-700: oklch(50.8% 0.118 165.612);
  --color-red-50: oklch(97.1% 0.013 17.38);
  --color-red-700: oklch(50.5% 0.213 27.518);
  --color-zinc-100: oklch(96.7% 0.001 286.375);
}
* {
  box-sizing:border-box
}
body {
  margin:0;
  background:var(--color-zinc-50);
  color:var(--color-zinc-950);
  font:16px/1.55 system-ui,sans-serif
}
main {
  max-width:1080px;
  margin:auto;
  padding:36px 24px 56px
}
header {
  display:flex;
  align-items:flex-start;
  justify-content:space-between;
  gap:24px;
  margin-bottom:28px
}
h1 {
  font-size:clamp(28px,4vw,38px);
  line-height:1.15;
  letter-spacing:-.035em;
  margin:0 0 12px
}
h2 {
  font-size:21px;
  margin:0 0 16px
}
h3 {
  font-size:17px;
  margin:0
}
p {
  margin:8px 0 16px
}
.lead {
  max-width:640px;
  color:var(--color-zinc-600)
}
a {
  color:var(--color-amber-900);
  text-underline-offset:4px
}
.grid {
  display:grid;
  grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);
  gap:24px;
  align-items:start
}
.card {
  background:var(--color-white);
  border:1px solid var(--color-zinc-200);
  border-radius:18px;
  padding:26px
}
.tint {
  background:var(--color-amber-50);
  border-color:var(--color-amber-100)
}
label {
  display:block;
  font-size:14px;
  font-weight:600;
  margin:18px 0 6px
}
input {
  width:100%;
  font:inherit;
  padding:11px 12px;
  border:1px solid var(--color-zinc-200);
  border-radius:9px;
  background:var(--color-white);
  color:inherit
}
input:focus {
  outline:2px solid var(--color-amber-500);
  outline-offset:2px
}
.pair {
  display:grid;
  grid-template-columns:1fr 1fr;
  gap:16px
}
.check {
  display:flex;
  gap:10px;
  align-items:flex-start;
  font-weight:400
}
.check input {
  width:18px;
  height:18px;
  flex:none;
  margin:3px 0;
  accent-color:var(--color-amber-800)
}
button {
  font:inherit;
  font-size:14px;
  font-weight:600;
  cursor:pointer;
  padding:11px 16px;
  border-radius:9px;
  border:1px solid var(--color-zinc-200);
  background:var(--color-white);
  color:inherit
}
button:hover {
  border-color:var(--color-zinc-500)
}
button:disabled {
  opacity:.45;
  cursor:default
}
.primary {
  background:var(--color-black);
  color:var(--color-white);
  border-color:var(--color-black)
}
.actions {
  display:flex;
  gap:10px;
  flex-wrap:wrap;
  align-items:center;
  margin-top:20px
}
.steps {
  margin:0;
  padding-left:22px
}
.steps li {
  padding:8px 0
}
.steps strong {
  display:block
}
.muted {
  color:var(--color-zinc-600);
  font-size:14px
}
.notice {
  border-left:3px solid var(--color-amber-500);
  padding-left:14px;
  margin:20px 0
}
.message {
  padding:14px 16px;
  border:1px solid var(--color-zinc-200);
  border-radius:10px;
  margin:16px 0;
  background:var(--color-white)
}
.message:empty {
  display:none
}
.success {
  background:var(--color-emerald-50);
  color:var(--color-emerald-700)
}
.error {
  background:var(--color-red-50);
  color:var(--color-red-700)
}
.stats {
  display:grid;
  grid-template-columns:repeat(3,1fr);
  gap:16px;
  margin-bottom:24px
}
.stat {
  padding:18px 20px
}
.stat p {
  margin:0;
  font-size:13px;
  color:var(--color-zinc-600)
}
.stat strong {
  display:block;
  font-size:28px;
  margin-top:4px
}
.request {
  border-top:1px solid var(--color-zinc-200);
  padding:18px 0
}
.request:first-child {
  border-top:0
}
.request:last-child {
  padding-bottom:0
}
.row {
  display:flex;
  justify-content:space-between;
  gap:16px;
  align-items:start
}
.badge {
  display:inline-flex;
  flex:none;
  font-size:12px;
  padding:3px 9px;
  border-radius:20px;
  background:var(--color-amber-50);
  color:var(--color-amber-900)
}
.verified {
  background:var(--color-emerald-50);
  color:var(--color-emerald-700)
}
.uncertain,.created {
  background:var(--color-amber-50);
  color:var(--color-amber-800)
}
.rejected {
  background:var(--color-zinc-100);
  color:var(--color-zinc-600)
}
dl {
  margin:0
}
dt {
  font-size:13px;
  color:var(--color-zinc-600);
  margin-top:14px
}
dd {
  margin:3px 0 0;
  overflow-wrap:anywhere
}
.receipt {
  font-size:18px;
  overflow-wrap:anywhere;
  font-weight:600
}
.login {
  max-width:480px;
  margin:30px auto
}
footer {
  margin-top:28px;
  color:var(--color-zinc-600);
  font-size:13px
}
.empty {
  padding:26px 0;
  color:var(--color-zinc-600)
}
[hidden] {
  display:none!important
}
@media(max-width:700px) {
  main {
  padding:24px 18px
}
.grid,.pair {
  grid-template-columns:1fr
}
.card {
  padding:20px
}
header {
  flex-direction:column;
  gap:10px
}
.stats {
  gap:8px
}
.stat {
  padding:13px
}
.row {
  flex-wrap:wrap
}

}


textarea {width:100%;font:inherit} input[type=file] {max-width:100%;padding:16px} .order-layout {display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr);gap:24px;margin-top:24px;align-items:start} .invoice-lines {width:100%;border-collapse:collapse;margin:22px 0} .invoice-lines td,.invoice-lines th {padding:12px 0;border-bottom:1px solid var(--color-zinc-200);text-align:left;font-size:14px} .invoice-lines td:last-child,.invoice-lines th:last-child {text-align:right;padding-left:16px;white-space:nowrap} .invoice-lines small {display:block;font-weight:400;color:var(--color-zinc-600);margin-top:4px} .total {display:flex;justify-content:space-between;gap:20px;font-size:22px;font-weight:700} .pending {background:var(--color-amber-50);color:var(--color-amber-900)} .reserved {background:var(--color-red-50);color:var(--color-red-700)} .request p {overflow-wrap:anywhere} @media(max-width:800px){.order-layout{grid-template-columns:1fr}}
`;
const markup = `
<header>
<div>
<h1>Shop orders to draft invoices</h1>
<p class="lead">Import your club-shop orders, review the customer and accounting allocation, and create each unsent draft once.</p>
</div>
</header>
<div class="stats">
<section class="card stat">
<p>Waiting for review</p>
<strong id="pending-count">0</strong>
</section>
<section class="card stat">
<p>Verified drafts</p>
<strong id="verified-count">0</strong>
</section>
<section class="card stat">
<p>Needs reconciliation</p>
<strong id="unresolved-count">0</strong>
</section>
</div>
<div class="actions">
<button id="refresh" type="button">Refresh desk</button>
<span id="write-mode" class="muted"></span>
</div>
<details class="card tint" id="import-panel" open>
<summary>Import the shop’s order export</summary>
<p class="muted">Use the documented JSON contract for open, unpaid orders that have not been invoiced. Include shipping as a separate mapped line. The complete file is checked before the queue changes.</p>
<label for="order-file">Order JSON file</label>
<input type="file" id="order-file" accept=".json,application/json">
<div class="actions">
<button id="import" type="button" class="primary">Import orders</button>
</div>
</details>
<p id="message" class="message" role="status" aria-live="polite"></p>
<div class="order-layout">
<section class="card">
<h2>Order queue</h2>
<div id="orders">
</div>
</section>
<section class="card" id="review-panel">
<h2>Review the draft</h2>
<p id="review-empty" class="muted">Choose an order to inspect its customer, lines, dates and accounts.</p>
<div id="review-content" hidden>
<h3 id="review-title">
</h3>
<dl id="review-fields">
</dl>
<table class="invoice-lines">
<thead>
<tr>
<th>Invoice line</th>
<th>Gross total</th>
</tr>
</thead>
<tbody id="review-lines">
</tbody>
</table>
<p class="total">
<span>Total</span>
<span id="review-total">
</span>
</p>
<p class="notice muted">Creates an unsent draft. No payment, deposit use, customer creation, or invoice issuing is requested.</p>
<label class="check">
<input type="checkbox" id="confirm-source">
<span>I checked the current shop order: no other system has invoiced it or collected payment, and campai will own its invoicing.</span>
</label>
<label class="check">
<input type="checkbox" id="lost-result">
<span>Test reconciliation after an interrupted result</span>
</label>
<div class="actions">
<button type="button" id="create" class="primary" disabled>Create this draft</button>
</div>
</div>
</section>
</div>
<footer>Private local review desk · Your shop adapter owns the customer and SKU mapping. Saved reservations prevent a repeat create; keep them when restarting.</footer>
`;
const client = `
const $ = id => document.getElementById(id);
let revision = 0, review = null, enabled = false, busy = false, currency = 'EUR';
const labels = { pending: 'Ready for review', reserved: 'Reserved · inspect', created: 'Created · verify', verified: 'Verified draft' };
const money = value => new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(value / 100);
const node = (tag, text, cls) => { const e = document.createElement(tag); e.textContent = text; if (cls)
    e.className = cls; return e; };
function message(text, error = false) { $('message').textContent = text; $('message').className = 'message ' + (error ? 'error' : 'success'); }
async function call(path, body) { const response = await fetch(path, { method: body === undefined ? 'GET' : 'POST', headers: body === undefined ? {} : { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, body: body === undefined ? undefined : JSON.stringify(body) }); const result = await response.json(); if (!response.ok)
    throw Error(result.error || 'Request failed.'); return result; }
function clearReview() { review = null; $('review-content').hidden = true; $('review-empty').hidden = false; $('confirm-source').checked = false; $('lost-result').checked = false; $('create').disabled = true; }
async function refresh() {
    const result = await call('/api/orders');
    revision = result.revision;
    currency = result.currency;
    enabled = result.allowDrafts;
    $('import-panel').open = result.orders.length === 0;
    clearReview();
    $('pending-count').textContent = result.orders.filter(r => r.status === 'pending').length;
    $('verified-count').textContent = result.orders.filter(r => r.status === 'verified').length;
    $('unresolved-count').textContent = result.orders.filter(r => ['reserved', 'created'].includes(r.status)).length;
    $('write-mode').textContent = enabled ? 'Draft creation enabled' : 'Draft creation disabled in server configuration';
    $('orders').replaceChildren();
    if (!result.orders.length)
        $('orders').append(node('p', 'No orders imported yet.', 'empty'));
    for (const row of result.orders.slice().reverse()) {
        const card = node('article', '', 'request'), heading = node('div', '', 'row');
        heading.append(node('h3', row.input.orderId), node('span', labels[row.status], 'badge ' + row.status));
        card.append(heading, node('p', row.input.customerId + ' · ' + row.input.lines.length + ' lines · ' + money(row.input.totalGrossAmount), 'muted'));
        if (row.receiptId)
            card.append(node('p', 'campai draft ID: ' + row.receiptId, 'muted'));
        if (row.error)
            card.append(node('p', row.error, 'message error'));
        const actions = node('div', '', 'actions');
        if (row.status === 'pending') {
            const b = node('button', 'Review order');
            b.type = 'button';
            b.addEventListener('click', () => select(row.key));
            actions.append(b);
        }
        if (['reserved', 'created'].includes(row.status)) {
            const field = node('input', '');
            field.id = 'receipt-' + row.key;
            field.type = 'text';
            field.value = row.receiptId || '';
            field.maxLength = 24;
            field.readOnly = !!row.receiptId;
            const label = node('label', 'Existing draft ID');
            label.htmlFor = field.id;
            card.append(label, field);
            const b = node('button', 'Verify existing draft');
            b.type = 'button';
            b.addEventListener('click', async () => { if (busy)
                return; busy = true; b.disabled = true; try {
                await call('/api/reconcile', { key: row.key, revision, receiptId: field.value.trim() });
                await refresh();
                message('The existing draft matches the reserved order. No create was repeated.');
            }
            catch (error) {
                message(error.message, true);
            }
            finally {
                busy = false;
                b.disabled = false;
            } });
            actions.append(b);
        }
        card.append(actions);
        $('orders').append(card);
    }
}
async function select(key) { if (busy)
    return; try {
    review = await call('/api/preview', { key, revision });
    const p = review.payload;
    $('review-title').textContent = review.order.input.orderId + ' · ' + p.accountName;
    $('review-fields').replaceChildren();
    for (const [label, value] of [['Billing address', [p.address.addressLine, p.address.zip + ' ' + p.address.city, p.address.country].join(', ')], ['Debtor account', String(p.account)], ['Invoice / due date', review.order.input.receiptDate + ' / ' + review.order.input.dueDate]])
        $('review-fields').append(node('dt', label), node('dd', value));
    $('review-lines').replaceChildren();
    for (const line of p.positions) {
        const tr = node('tr', ''), description = node('td', line.description);
        description.append(node('small', line.quantity + ' × ' + money(line.unitAmount) + ' / ' + line.unit), node('small', 'Revenue ' + line.account + ' · Cost centers ' + (line.costCenter1 ?? '—') + ' / ' + (line.costCenter2 ?? '—') + ' · Tax ' + (line.taxCode ?? 'none')));
        tr.append(description, node('td', money(line.quantity * line.unitAmount)));
        $('review-lines').append(tr);
    }
    $('review-total').textContent = money(review.total);
    $('review-content').hidden = false;
    $('review-empty').hidden = true;
    $('confirm-source').checked = false;
    $('create').disabled = true;
    $('review-panel').scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
catch (error) {
    clearReview();
    message(error.message, true);
} }
$('import').addEventListener('click', async () => { if (busy)
    return; busy = true; $('import').disabled = true; try {
    const file = $('order-file').files[0];
    if (!file || file.size > 128000)
        throw Error('Choose an order JSON file up to 128 KB.');
    const result = await call('/api/import', JSON.parse(await file.text()));
    await refresh();
    message(result.added + ' added, ' + result.updated + ' updated, ' + result.unchanged + ' unchanged. No invoice was created.');
}
catch (error) {
    message(error.message, true);
}
finally {
    busy = false;
    $('import').disabled = false;
} });
$('refresh').addEventListener('click', () => refresh().catch(error => message(error.message, true)));
$('confirm-source').addEventListener('change', () => { $('create').disabled = !review || !enabled || !$('confirm-source').checked; });
$('create').addEventListener('click', async () => { if (busy || !review || !$('confirm-source').checked)
    return; busy = true; $('create').disabled = true; const selected = review; try {
    const result = await call('/api/create', { key: selected.order.key, revision: selected.revision, reviewToken: selected.reviewToken, confirmedSource: true, simulateLostResult: $('lost-result').checked });
    await refresh();
    message(result.order.status === 'verified' ? 'One unsent draft was created and read back from campai.' : 'The result needs reconciliation. Keep its reservation and do not create again.', result.order.status !== 'verified');
}
catch (error) {
    message(error.message, true);
    await refresh().catch(() => { });
}
finally {
    busy = false;
} });
refresh().catch(error => message(error.message, true));

`;
function page(nonce) { return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>Shop order review desk</title><style nonce="${nonce}">${styles}</style></head><body><main>${markup}</main><script nonce="${nonce}">const csrf=${JSON.stringify(csrf)};${client}</script></body></html>`; }

The complete desk runs in Node.js. The cURL, Node.js and PHP tabs show the underlying request without a shared client. Continue with Find and filter receipts to design a separate status reader.

Check your result

Reimport the same orders
Both source references and saved outcomes stay unchanged.
Interrupt the result
The existing draft can be reconciled by its saved ID.
Import a paid order
Validation blocks it before any campai call.
Continue with Find and filter receipts