Senior Application Developer

loc: Maynooth, ON K0L 2S0tel: 613-553-0960email: rgsamways@gmail.com
Robin Samways

Farpost · Payments

$ A Stripe Default That Produces a $0 Invoice — Silently

Stripe lets you attach a pending invoice item to a customer, then create an invoice that's supposed to pull it in. What Stripe doesn't do is default to including it — omit one specific parameter and the invoice comes back empty, for $0, and gets auto-marked as paid. No error, no warning. The platform fee that invoice item represented is left sitting nowhere.

A second, related surprise showed up in the same file: a freshly created draft invoice's hosted URL comes back as null. Stripe only fills it in once the invoice is finalized — a caller expecting the link immediately after creation gets nothing, silently.

async def create_platform_fee_invoice_draft(customer_id: str, fee_cents: int, description: str) -> str:
    """Create a draft platform fee invoice without finalizing."""
    client = _client()
    client.invoice_items.create(params={
        "customer": customer_id, "amount": fee_cents, "currency": "cad",
        "description": description,
    })
    invoice = client.invoices.create(params={
        "customer": customer_id,
        "collection_method": "send_invoice",
        "days_until_due": 30,
        # Without this, Stripe defaults to "exclude" and the pending invoice item
        # created above never attaches — the invoice comes back empty ($0, auto-marked
        # paid) while the real fee sits stranded.
        "pending_invoice_items_behavior": "include",
    })
    return invoice.id

async def finalize_and_send_platform_fee_invoice(invoice_id: str, fee_cents: int) -> str | None:
    """Confirmed empirically: a draft invoice's hosted_invoice_url is None — Stripe
    only populates it once finalize_invoice() runs."""
    client = _client()
    finalized = client.invoices.finalize_invoice(invoice_id)
    client.invoices.send_invoice(invoice_id)
    return finalized.hosted_invoice_url

The fix

pending_invoice_items_behavior: "include" has to be passed explicitly when creating the invoice, or Stripe defaults to excluding the pending item that was just created for it. And hosted_invoice_url is only populated after finalize_invoice() runs — a caller has to capture it from that call's return value, not from the draft-creation response.

Why this matters

Both of these were found empirically, not from documentation — a bug that produces zero errors and just quietly returns wrong or missing data is the hardest kind to catch by inspection alone, and the code says outright that it was confirmed by testing against the real API rather than assumed from Stripe's docs. That's verification discipline: not trusting an integration to behave the way its documentation implies, and checking the actual behavior instead.

Feedback on this page