Invoice Items and Payments

2k Aufrufe Markdown

The five endpoints that edit, split and merge invoice lines and hold the payment records.

Overview

These five endpoints run the money side of an invoice. Three deal with its lines: editing them, moving some onto an invoice of their own, and folding other invoices into it. The other two deal with payment records.

The line endpoint works in bulk: one call adds, updates and removes. When it is done the totals are worked out again. The subtotal, the tax and the grand total are never written by hand.

A payment record moves the invoice status both ways. Clearing the balance turns it paid. Removing a payment the remaining ones no longer cover turns it back to unpaid.

Reference

Writing the Lines

put/api/v1/admin/invoices/{id}/items
Invoices/UpdateInvoiceItems admin the totals are recomputed

Adds, updates and removes an invoice's lines in one call.

Body 3
itemsarrayThe lines to work on. One carrying an id gets updated, one without is added as new.
deleted_idsint[]The line ids to remove.
removed_discountsarrayThe discounts to take off. Each entry carries the discount type and the line it belongs to.
Line fields items[] — 10
item_idintThe id of an existing line. Left out, a new line is opened.
descriptionstringreqWhat the line is for.
quantityintreqHow many.
amountfloatreqThe unit amount.
discountfloatA discount on this line.
discount_typestringWhether the discount is an amount or a share.
tax_ratefloatA tax rate for this line. Left empty, the invoice's rate is used.
tax_exemptboolKeeps the line free of tax.
user_pidintThe service it covers.
oduedatestringThe end of the line's period.
Response fields data
dataobjectThe invoice as it now stands. Its totals arrive recomputed.
Errors 3
not_found404No such invoice.
item_required422No line would remain when the call is done.
insufficient_scope403The key lacks the required scope.
Request
curl -X PUT 'https://panel.example.com/api/v1/admin/invoices/1212/items' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"items":[{"item_id":2811,"description":"Hosting","quantity":1,"amount":12.5}],"deleted_ids":[2812]}'
const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/items`, {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    items: [
      { item_id: 2811, description: 'Hosting Plan', quantity: 1, amount: 12.5 },
      { description: 'Setup Fee', quantity: 1, amount: 5 },
    ],
    deleted_ids: [2812],
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/items');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'PUT',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'items' => [
            ['item_id' => 2811, 'description' => 'Hosting Plan', 'quantity' => 1, 'amount' => 12.5],
            ['description' => 'Setup Fee', 'quantity' => 1, 'amount' => 5],
        ],
        'deleted_ids' => [2812],
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// A line you leave out is NOT removed; to remove one, put its id in the deleted list.
Api::Invoices()->UpdateInvoiceItems([
    'id'          => $id,
    'items'       => [['item_id' => 2811, 'description' => 'Hosting', 'quantity' => 1, 'amount' => 12.5]],
    'deleted_ids' => [2812],
]);

Splitting the Lines

post/api/v1/admin/invoices/{id}/items/split
Invoices/SplitInvoiceItems admin opens a new invoice

Moves the lines you pick onto a new invoice.

Body 1
item_idsint[]reqThe lines to move. All have to belong to the source, and one line has to stay behind.
Response fields data — 4
source_invoice_idintThe source invoice id.
new_invoice_idintThe invoice opened.
new_invoice_numberstring | nullThe new invoice's number. Empty until a number is given.
moved_item_idsint[]The lines that moved.
Errors 6
not_found404No such invoice.
split_required422No line was picked.
no_items422The lines do not belong to this invoice.
split_one_left422No line would stay on the source.
split_failed422The new invoice could not be opened.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/invoices/1228/items/split' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"item_ids":[2835]}'
const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/items/split`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ item_ids: [2835] }),
});

const { data } = await res.json();
console.log(data.new_invoice_id);
$ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/items/split');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['item_ids' => [2835]]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The new invoice opens UNPAID and the client is NOT told about it by itself.
$r = Api::Invoices()->SplitInvoiceItems(['id' => $id, 'item_ids' => $picked])['data'];
Api::Invoices()->SendInvoiceNotification([
    'id' => $r['new_invoice_id'], 'template' => 'invoice-created',
]);

Merging Invoices

post/api/v1/admin/invoices/{id}/merge
Invoices/MergeInvoices admin deletes the folded invoices

Folds other unpaid invoices of the same client into the invoice in the path.

Body 1
invoice_idsint[]reqThe invoices to fold into this one. At least one; the main invoice's own id is ignored.
Response fields data + meta — 2
dataobjectThe main invoice after the merge. Same shape as reading the invoice; the moved lines are in its items.
invoice_idintThe main invoice id. It comes back under meta.
merged_invoice_idsint[]The invoices folded in and deleted. It comes back under meta.
Errors 12
not_found404No such invoice.
invalid_invoice_ids422The list does not hold invoice ids.
merge_min_two422No other invoice was given.
merge_not_found422Some of the invoices were not found.
merge_not_unpaid422An invoice is not unpaid.
merge_different_client422An invoice belongs to another client.
merge_has_payments422An invoice already has payment records.
merge_formalized422An invoice is formalized.
merge_settings_mismatch422The tax settings or the billing profile differ from the main invoice.
merge_payment_flow422An invoice is tied to a subscription, an installment plan or an open checkout.
merge_coupon422An invoice other than the main one carries a coupon.
insufficient_scope403The key lacks the required scope. The key's owner also needs the invoice delete privilege.

Each merge_* refusal names the invoices that blocked it in details.invoice_ids. Every check runs first, so a refusal leaves all invoices as they were.

Request
curl -X POST 'https://panel.example.com/api/v1/admin/invoices/1212/merge' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"invoice_ids":[1229,1235]}'
const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/merge`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ invoice_ids: [1229, 1235] }),
});

const body = await res.json();
if (!res.ok) console.log(body.error.code, body.error.details?.invoice_ids);
$ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/merge');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['invoice_ids' => [1229, 1235]]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// A refusal comes back as an error envelope and leaves every invoice as it was.
$r = Api::Invoices()->MergeInvoices(['id' => $mainId, 'invoice_ids' => $others]);
$gone = $r['meta']['merged_invoice_ids'] ?? [];

Recording a Payment

post/api/v1/admin/invoices/{id}/payments
Invoices/AddInvoicePayment admin clears the balance to paid

Adds a payment record to an invoice by hand.

Body 7
amountfloatreqThe amount paid. It has to be above zero.
payment_methodstringreqHow the money came in, as the payment module's name. Balance takes the amount from the client's account balance; a balance that does not cover it refuses the payment. On an open invoice with no payment yet, the invoice is first repriced with this method's current commission rate and takes this method, part payments included; what is still owed follows the new total.
currency_idintThe payment currency. It gets converted when it differs from the invoice.
transaction_idstringThe transaction number. A second record with the same one is refused.
descriptionstringWhat the payment was.
paid_atstringThe date it was paid.
feesfloatThe fee taken.
Response fields data + meta — 1
dataobjectThe invoice as it now stands. Once the balance clears, the status turns to paid.
payment_idintThe id of the payment record opened. It comes back under meta.
Errors 6
not_found404No such invoice.
invalid_amount422The amount is zero or below.
method_required422No payment method was given.
payment_rejected422The invoice is already paid, the transaction number repeats, the currency is not valid, or the amount is more than is still owed. On an invoice with no payment yet, what is still owed follows the total repriced for the method sent; a refused payment leaves the invoice and the balance as they were.
insufficient_balance422The method is Balance and the client's account balance does not cover the amount. The details carry what is required and what is available, in the balance currency.
insufficient_scope403The key lacks the required scope.
Request
curl -X POST 'https://panel.example.com/api/v1/admin/invoices/1212/payments' \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"amount":12.5,"payment_method":"BankTransfer","transaction_id":"TXN-1042"}'
const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/payments`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount: 12.5,
    payment_method: 'BankTransfer',
    currency_id: 840,
    transaction_id: 'TXN-1042',
  }),
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/payments');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . $apiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount'         => 12.5,
        'payment_method' => 'BankTransfer',
        'transaction_id' => 'TXN-1042',
    ]),
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// The transaction number guards against DOUBLES; retrying after a network error is safe.
Api::Invoices()->AddInvoicePayment([
    'id'             => $id,
    'amount'         => 12.5,
    'payment_method' => 'BankTransfer',
    'transaction_id' => $txn,
]);

Removing a Payment

delete/api/v1/admin/invoices/{id}/payments/{payment_id}
Invoices/DeleteInvoicePayment admin an uncovered invoice reopens

Takes a payment record off an invoice.

Response fields data
dataobjectThe invoice as it now stands. What was paid and what remains are worked out again. A paid invoice the remaining payments no longer cover is unpaid again. A payment that took its amount from the account balance gives it back, with a matching balance movement.
Errors 3
not_found404That payment is not on this invoice.
invalid_payment422The payment id is not valid.
insufficient_scope403The key lacks the required scope.
Request
curl -X DELETE 'https://panel.example.com/api/v1/admin/invoices/1212/payments/31' \
  -H "Authorization: Bearer $API_KEY"
const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/payments/${paymentId}`, {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${apiKey}` },
});

const body = await res.json();
$ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/payments/' . $paymentId);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'DELETE',
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $apiKey],
]);

$body = json_decode(curl_exec($ch), true);
curl_close($ch);
// A paid invoice the remaining payments no longer cover turns unpaid by itself.
Api::Invoices()->DeleteInvoicePayment(['id' => $id, 'payment_id' => $pid]);

Pitfalls

Leaving a line out does not remove it

A line you do not send on the write call stays put. Removing it means putting its id in the deleted list. A script that sends the line set as it stands will not remove what was taken out. Compare the two lists and write the difference into the deleted list.

An invoice cannot be left with no lines

Both the write and the split call want at least one line left on the invoice when they finish. Trying to delete every line, or to split them all away, gives an error. To be rid of the invoice, remove or cancel the invoice itself rather than its lines.

Do not write the totals by hand

When the write call finishes, the subtotal, the tax and the grand total are worked out again. Trying to write a total through the invoice edit endpoint at the same time leaves a record pulled by two sources. Totals come from the lines, and changing them means changing the lines.

Removing a payment walks back only the status and the balance

When the remaining payments no longer cover a paid invoice, removing a payment turns it back to unpaid by itself. An amount taken from the account balance goes back to it. A top-up invoice takes its loaded credit back too. Nothing else is walked back: extended service periods and activated orders stay as they are.

A split brings a new debt into being

The split call opens a new unpaid invoice and tells the client nothing by itself. The client is left with another debt they have not heard of. After a split, take the new invoice id and send a notice. The source total drops as well, so both documents have changed.

A merge deletes the other invoices

The merge call keeps only the invoice in the path. The others are deleted once their lines have moved, so their ids stop working. Orders and services that pointed to them now point to the main invoice. Keep the ids from the response if your own records refer to them.

War das hilfreich?

Vielen Dank für Ihre Rückmeldung!

Brauchen Sie weitere Hilfe?

Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.