Invoices
An invoice is a structured bill addressed to a specific customer, with line items, totals, and a status that walks from draft → open → paid (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
- Receipts — issued on successful payment.
- Refunds — refunding a paid invoice.
- Subscriptions — the source of auto-issued invoices.
- API: Invoices — HTTP reference.