Invoices

An invoice is a structured bill addressed to a specific customer, with line items, totals, and a status that walks from draftopenpaid (or void / uncollectible). Subscriptions auto-issue them; you can also create one-off invoices for net-30 customers, custom quotes, or proration. This page covers the plugipay.invoices namespace; for the underlying HTTP surface see API: Invoices, and for the lifecycle see Concepts → Invoice.

Namespace

plugipay.invoices — every method on this namespace:

plugipay.invoices.create(input)
plugipay.invoices.get(id)
plugipay.invoices.list(params?)
plugipay.invoices.finalize(id)
plugipay.invoices.pay(id)
plugipay.invoices.void(id)
plugipay.invoices.sendEmail(id, to?)

Methods

invoices.create

Signature. plugipay.invoices.create(input): Promise<Invoice>

Creates an invoice for a customer. Status defaults to 'draft'; pass status: 'open' to skip the draft phase and finalize immediately. dueAt is optional — if omitted, the invoice is due on issue.

import { PlugipayClient } from '@forjio/plugipay-node';

const plugipay = new PlugipayClient({
  keyId: process.env.PLUGIPAY_KEY_ID!,
  secret: process.env.PLUGIPAY_SECRET!,
});

const invoice = await plugipay.invoices.create({
  customerId: 'cus_01HX...',
  currency: 'IDR',
  lines: [
    { description: 'Consulting — November', quantity: 40, unitAmount: 500_000_00 },
    { description: 'Add-on monitoring',     quantity: 1,  unitAmount: 250_000_00 },
  ],
  tax: 1_725_000_00,                  // 12% PPN on the subtotal
  dueAt: '2026-12-01T00:00:00Z',
  memo: 'Net-30 — PO #2026-118',
});

invoices.get

Signature. plugipay.invoices.get(id): Promise<Invoice>

const invoice = await plugipay.invoices.get('inv_01HX...');
console.log(`${invoice.number}: ${invoice.amountDue} ${invoice.currency} due ${invoice.dueAt}`);

invoices.list

Signature. plugipay.invoices.list(params?): Promise<{ data: Invoice[]; cursor: string | null; hasMore: boolean }>

Filters: status, customerId, limit, cursor. See Pagination.

// All open invoices for a customer (pending receivables):
const { data } = await plugipay.invoices.list({
  customerId: 'cus_01HX...',
  status: 'open',
  limit: 100,
});

const outstanding = data.reduce((sum, inv) => sum + inv.amountDue, 0);

invoices.finalize

Signature. plugipay.invoices.finalize(id): Promise<Invoice>

Locks a draft invoice. After finalization the line items, taxes, and totals are frozen — you can void it but you can't edit it. Required before pay() or sendEmail().

await plugipay.invoices.finalize('inv_01HX...');

invoices.pay

Signature. plugipay.invoices.pay(id): Promise<Invoice>

Triggers a payment attempt. The invoice's customer must have a configured payment token (otherwise the call returns the invoice still in open status with no charge attempted). Idempotent — calling it twice on the same invoice doesn't double-charge.

const paid = await plugipay.invoices.pay('inv_01HX...');
if (paid.status === 'paid') {
  console.log(`Paid ${paid.amountPaid} on ${paid.paidAt}`);
}

invoices.void

Signature. plugipay.invoices.void(id): Promise<Invoice>

Voids an invoice. Status flips to 'void'; any outstanding amount is cleared. You can't void a paid invoice — refund the underlying payment instead.

await plugipay.invoices.void('inv_01HX...');

invoices.sendEmail

Signature. plugipay.invoices.sendEmail(id, to?: string): Promise<{ sent: boolean; to: string }>

Emails the hosted invoice link to the customer (using their email) or to an override address. The invoice must be open or paid — you can't email a draft.

// Send to the customer's email on file:
await plugipay.invoices.sendEmail('inv_01HX...');

// Send to a specific address (e.g. their AP team):
await plugipay.invoices.sendEmail('inv_01HX...', 'ap@bigcorp.id');

Types

interface Invoice {
  id: string;                  // 'inv_...'
  accountId: string;
  customerId: string;
  status: 'draft' | 'open' | 'past_due' | 'paid' | 'void' | 'uncollectible';
  number: string;              // human-readable, e.g. 'INV-2026-00118'
  currency: 'IDR' | 'USD';
  subtotal: number;
  discount: number;
  tax: number;
  total: number;
  amountPaid: number;
  amountDue: number;
  dueAt: string | null;
  issuedAt: string | null;
  paidAt: string | null;
  hostedInvoiceUrl: string | null;
  lines: InvoiceLine[];
  createdAt: string;
  updatedAt: string;
}

interface InvoiceLine {
  id: string;
  description: string;
  quantity: number;
  unitAmount: number;
  amount: number;              // quantity * unitAmount
}

For the full reference including the tax-calculation logic and hostedInvoiceUrl lifecycle, see API: Invoices.

Common patterns

One-off invoice for a net-30 customer

Draft → finalize → email. The customer pays via the hosted invoice URL on their own time.

const draft = await plugipay.invoices.create({
  customerId: 'cus_01HX...',
  currency: 'IDR',
  lines: [{ description: 'October retainer', quantity: 1, unitAmount: 25_000_000_00 }],
  tax: 2_500_000_00,
  dueAt: '2026-11-30T00:00:00Z',
  memo: 'PO #2026-118',
});

await plugipay.invoices.finalize(draft.id);
await plugipay.invoices.sendEmail(draft.id);

Charge a saved card immediately

For customers with a payment token on file, you can issue and charge in one flow:

const open = await plugipay.invoices.create({
  customerId: 'cus_01HX...',
  currency: 'IDR',
  lines: [{ description: 'Annual upgrade', quantity: 1, unitAmount: 5_000_000_00 }],
  status: 'open',                       // skip the draft phase
});

const paid = await plugipay.invoices.pay(open.id);
console.log(paid.status); // → 'paid' if their card succeeded

Reconcile receivables

The accounts-receivable view in your dashboard is just a filtered list:

async function* outstandingInvoices() {
  let cursor: string | undefined;
  while (true) {
    const page = await plugipay.invoices.list({ status: 'open', limit: 100, cursor });
    for (const inv of page.data) yield inv;
    if (!page.hasMore) break;
    cursor = page.cursor!;
  }
}

let total = 0;
for await (const inv of outstandingInvoices()) total += inv.amountDue;
console.log(`Total receivables: ${total} IDR`);

React to invoice events via webhooks

Plugipay emits plugipay.invoice.created.v1, .finalized.v1, .paid.v1, .payment_failed.v1, and .voided.v1. The .paid.v1 event is your fulfillment trigger:

import { verifyWebhook } from '@forjio/plugipay-node';

app.post('/webhooks/plugipay', async (req, res) => {
  const event = verifyWebhook({
    body: req.rawBody,
    signature: req.header('Plugipay-Signature')!,
    secret: process.env.PLUGIPAY_WEBHOOK_SECRET!,
  });

  if (event.type === 'plugipay.invoice.paid.v1') {
    const inv = event.data.object;
    await markRetainerPaid(inv.customerId, inv.number);
  }
  res.sendStatus(200);
});

Void instead of refund (pre-payment)

If the customer says "wait, we don't owe this", and the invoice is still open, void rather than create a refund — refunds only apply to paid charges.

async function cancelInvoice(invId: string) {
  const inv = await plugipay.invoices.get(invId);
  if (inv.status === 'open' || inv.status === 'draft') {
    return plugipay.invoices.void(invId);
  }
  // For 'paid', issue a refund instead:
  return plugipay.refunds.create({ sourceType: 'invoice', sourceId: invId });
}

Errors

Code Status Cause
validation_error 400 Missing customer/currency/lines, bad dueAt, non-positive quantity or amount.
not_found 404 Invoice ID doesn't exist.
state_conflict 409 Finalizing a non-draft invoice, paying a draft, voiding a paid invoice.
payment_token_missing 402 pay() called but customer has no token on file.

Next

Plugipay — Payments that don't tax your success