Repair Charges and money

Partial capture total mismatch

You captured part of an authorized Stripe charge, maybe because only half the order shipped, or stock ran short on one item. Stripe shows the smaller amount you actually took. The WooCommerce order still shows the full original total, as if nothing changed. Now your books do not agree with your payment processor, and nobody told the order. Here is why that gap opens up and a small script that lines the total up with what was really taken.

Python and Node.js Runs on a schedule Safe by default (dry run)
A pile of paper documents
Photo by soap so on Unsplash
The short answer

WooCommerce sets the order total once, when the order is placed, and never looks back at Stripe to see what was actually captured. A partial capture changes the real amount taken but leaves the order total untouched. Run a small Python or Node.js script on a schedule that reads each paid order's Stripe PaymentIntent, compares amount_received to the order total in minor units, and lowers the order total to match when a genuine partial capture is found. Full code, tests, and a dry run guard are below.

The problem in plain words

When a store uses manual capture, Stripe first authorizes the full amount and holds it. Later, someone (or some code) captures all or part of that authorization. A partial capture is a normal, supported thing to do: capture less when a warehouse can only fill part of an order, when an item goes out of stock after checkout, or when a merchant intentionally splits a charge across shipments.

The trouble is what happens next in WooCommerce. The order was created with a total based on what the cart contained at checkout. That total gets written once and stays there. Stripe's own record of the PaymentIntent updates the moment a partial capture happens, its amount_received field drops to the real amount taken, but nothing tells WooCommerce to go back and lower the order total to match. The order still reports the full price as paid in full.

Order authorized full amount, $80.00 Partial capture Stripe takes $50.00 total not updated Order still $80.00 shows as paid in full Books off by $30.00
Stripe knows the real amount the moment it captures less than the authorization. WooCommerce never asks, so the order total stays wrong.

Why it happens

WooCommerce and the Stripe gateway are built around a simple, one-way flow: create the order, authorize or charge the card, mark the order paid. A partial capture is a second event that happens after that flow already finished, and nothing in the standard integration listens for it.

Because the order total is treated as fixed once set, the gap sits there quietly. Nobody notices until a refund is attempted for more than was actually charged, or a bookkeeper reconciles Stripe payouts against WooCommerce reports and the numbers do not add up.

The key insight

Stripe's PaymentIntent is the source of truth for what money actually moved. Once a capture is final, amount_received is exactly what was taken from the card, in minor units. If the WooCommerce order total is higher than that, the order is wrong, not Stripe. A script that reads the truth from Stripe and brings the order total down to match repairs the gap without guessing at line items.

The fix, as a flow

We do not touch live checkout or the capture itself. We add a job that runs on a schedule, looks at recent paid orders, reads each one's saved PaymentIntent id, and checks whether the capture is finished and lower than the order total. When it is, we update the order total to the captured amount and leave a note explaining exactly why, so a human can still review it before anything ships or gets refunded.

Scheduled job every day or so List paid orders Processing, Completed Load PaymentIntent from saved intent id Capture done and total too high? yes no, skip Correct total save amount + note
The script only corrects orders where the capture has finished and the order total is genuinely higher than what Stripe took. Everything else is left alone.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="14"
export MISMATCH_TOLERANCE_MINOR="1"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="14"
export MISMATCH_TOLERANCE_MINOR="1"
export DRY_RUN="true"   // start safe, change to false to write
2

Read the saved PaymentIntent id from the order

The WooCommerce Stripe plugin saves the PaymentIntent id as order meta under _stripe_intent_id. Some setups only have it saved as transaction_id instead. Check both, and only trust a transaction_id if it actually looks like a PaymentIntent id, since some gateways store a charge id there instead.

step2.py
def intent_id_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None
step2.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}
3

Compare the amounts in minor units

Never compare dollars as floats. Convert the WooCommerce order total to cents, and read Stripe's amount_received, which is already in minor units. A capture that is still in progress reports a nonzero amount_capturable, and we skip those, since more money might still be captured later and comparing now would be premature.

step3.py
def order_total_minor(order):
    # Works for two decimal currencies. Zero decimal currencies (JPY and friends)
    # have their own guide, since round(x * 100) is wrong for those.
    return round(float(order["total"]) * 100)

def captured_minor(intent):
    return intent.get("amount_received", 0)

def to_major_str(minor):
    return f"{minor / 100:.2f}"
step3.js
export function orderTotalMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function capturedMinor(intent) {
  return intent.amount_received || 0;
}

export function toMajorStr(minor) {
  return (minor / 100).toFixed(2);
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order and its PaymentIntent and returns an action. The rule: skip anything not paid, skip anything Stripe has not finished capturing, fix an order whose total is higher than the captured amount, and flag (rather than auto fix) the rare case where the order total is already lower than the charge, since that is an overcharge that needs a person to decide the right refund.

decide.py
PAID_STATUSES = {"processing", "completed"}

def decide(order, intent, tolerance_minor=1):
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a paid state")
    if intent is None:
        return ("skip", "no Stripe PaymentIntent id on this order")
    if intent.get("status") not in ("succeeded", "requires_capture"):
        return ("skip", "intent has no capture to compare yet")
    if intent.get("amount_capturable", 0) > 0:
        return ("skip", "capture is still partial in progress, more may be captured")

    order_minor = order_total_minor(order)
    charged_minor = captured_minor(intent)
    drift = order_minor - charged_minor

    if abs(drift) <= tolerance_minor:
        return ("ok", "order total matches what Stripe captured")
    if drift < 0:
        return ("flag", f"order total is lower than the Stripe charge (drift {drift} minor units)")
    return ("fix", f"only {charged_minor} of {order_minor} minor units was captured, "
                   f"order total should drop to match")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);

export function decide(order, intent, toleranceMinor = 1) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["skip", "no Stripe PaymentIntent id on this order"];
  if (intent.status !== "succeeded" && intent.status !== "requires_capture") {
    return ["skip", "intent has no capture to compare yet"];
  }
  if ((intent.amount_capturable || 0) > 0) {
    return ["skip", "capture is still partial in progress, more may be captured"];
  }

  const orderMinor = orderTotalMinor(order);
  const chargedMinor = capturedMinor(intent);
  const drift = orderMinor - chargedMinor;

  if (Math.abs(drift) <= toleranceMinor) return ["ok", "order total matches what Stripe captured"];
  if (drift < 0) {
    return ["flag", `order total is lower than the Stripe charge (drift ${drift} minor units)`];
  }
  return ["fix", `only ${chargedMinor} of ${orderMinor} minor units was captured, order total should drop to match`];
}
5

Apply the fix and leave a paper trail

When the action is fix, update the order total through the WooCommerce REST API, then add an order note that names the old and new totals and the PaymentIntent id, so a shop manager can see exactly why the number changed. If it lands on flag instead, only leave a note, since a genuine overcharge deserves a human to pick the refund amount.

apply.py
def apply_fix(order, intent, reason):
    new_total = to_major_str(captured_minor(intent))
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"total": new_total},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Total corrected for a partial capture: {reason}. "
                      f"Order total set to {new_total} to match Stripe PaymentIntent "
                      f"{intent['id']}. Please review line items if this needs a refund too."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function applyFix(order, intent, reason) {
  const newTotal = toMajorStr(capturedMinor(intent));
  await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ total: newTotal }) });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Total corrected for a partial capture: ${reason}. Order total set to ` +
            `${newTotal} to match Stripe PaymentIntent ${intent.id}. Please review ` +
            `line items if this needs a refund too.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports what it would change. Read the output, confirm the totals it wants to correct look right, then switch it off to let it write. Run it on a schedule, once a day is usually enough since partial captures do not happen every minute.

Run it safe

Always start with DRY_RUN=true. Lowering an order total is a real change, so you want to see the exact list of affected orders and their new totals before anything writes. Once the report looks right for a few runs, turn it off.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only ever touches orders whose capture has finished and whose total is genuinely higher than what Stripe took.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

sync_partial_capture.py
"""Line up a WooCommerce order total with a partial Stripe capture.

A store on manual capture can capture less than the full authorized amount, for a
split shipment, a stock shortfall, or a deliberate partial charge. Stripe's
PaymentIntent then shows the real amount taken in `amount_received`, but the
WooCommerce order was created with the original, larger total and nothing updates
it. The order overstates what the buyer actually paid. This walks recent paid
orders, reads the saved Stripe PaymentIntent id from order meta
`_stripe_intent_id` (falling back to `transaction_id`), and for any order whose
total is higher than what Stripe actually captured, corrects the order total to
match and adds a note explaining the change. Safe by default, dry run first.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("sync_partial_capture")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "14"))
MISMATCH_TOLERANCE_MINOR = int(os.environ.get("MISMATCH_TOLERANCE_MINOR", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def order_total_minor(order):
    """Order total in minor units (cents). Two decimal currencies only; zero decimal
    currencies such as JPY have their own guide, since round(x * 100) is wrong there."""
    return round(float(order["total"]) * 100)


def captured_minor(intent):
    """What Stripe actually captured for this intent, in minor units."""
    return intent.get("amount_received", 0)


def to_major_str(minor):
    """Minor units back to a two decimal string suitable for a WooCommerce total."""
    return f"{minor / 100:.2f}"


def decide(order, intent, tolerance_minor=MISMATCH_TOLERANCE_MINOR):
    """Pure decision: given an order and its Stripe PaymentIntent, decide whether the
    order total needs to be brought down to the amount Stripe actually captured.
    No I/O here, so this is fully unit testable.
    """
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a paid state")
    if intent is None:
        return ("skip", "no Stripe PaymentIntent id on this order")
    if intent.get("status") not in ("succeeded", "requires_capture"):
        return ("skip", "intent has no capture to compare yet")
    if intent.get("amount_capturable", 0) > 0:
        return ("skip", "capture is still partial in progress, more may be captured")

    order_minor = order_total_minor(order)
    charged_minor = captured_minor(intent)
    drift = order_minor - charged_minor

    if abs(drift) <= tolerance_minor:
        return ("ok", "order total matches what Stripe captured")
    if drift < 0:
        # Order total is lower than what Stripe took. That is an overcharge, not a
        # partial capture, and deserves its own careful look rather than an auto-fix.
        return ("flag", f"order total is lower than the Stripe charge (drift {drift} minor units)")

    return (
        "fix",
        f"only {charged_minor} of {order_minor} minor units was captured, "
        f"order total should drop to match",
    )


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def paid_orders():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def apply_fix(order, intent, reason):
    new_total = to_major_str(captured_minor(intent))
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"total": new_total},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Total corrected for a partial capture: {reason}. "
                      f"Order total set to {new_total} to match Stripe PaymentIntent "
                      f"{intent['id']}. Please review line items if this needs a refund too."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def flag(order, intent, reason):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Capture check failed: {reason}. PaymentIntent "
                      f"{intent['id']}. Please review before shipping or refunding."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    flagged = 0
    for order in paid_orders():
        intent = get_intent(intent_id_of(order))
        action, reason = decide(order, intent)
        if action == "skip":
            continue
        if action == "flag":
            log.warning("Order %s: %s. %s", order["id"], reason, "would flag" if DRY_RUN else "flagging")
            if not DRY_RUN:
                flag(order, intent, reason)
            flagged += 1
            continue
        log.info("Order %s: %s. %s", order["id"], reason, "would fix" if DRY_RUN else "fixing")
        if not DRY_RUN:
            apply_fix(order, intent, reason)
        fixed += 1
    log.info(
        "Done. %d order(s) %s, %d order(s) %s.",
        fixed, "to fix" if DRY_RUN else "fixed",
        flagged, "to flag" if DRY_RUN else "flagged",
    )


if __name__ == "__main__":
    run()
sync-partial-capture.js
/**
 * Line up a WooCommerce order total with a partial Stripe capture.
 *
 * A store on manual capture can capture less than the full authorized amount, for a
 * split shipment, a stock shortfall, or a deliberate partial charge. Stripe's
 * PaymentIntent then shows the real amount taken in `amount_received`, but the
 * WooCommerce order was created with the original, larger total and nothing updates
 * it. The order overstates what the buyer actually paid. This walks recent paid
 * orders, reads the saved Stripe PaymentIntent id from order meta
 * `_stripe_intent_id` (falling back to `transaction_id`), and for any order whose
 * total is higher than what Stripe actually captured, corrects the order total to
 * match and adds a note explaining the change. Safe by default, dry run first.
 *
 * Guide: https://www.allanninal.dev/woocommerce/partial-capture-total-mismatch/
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const MISMATCH_TOLERANCE_MINOR = Number(process.env.MISMATCH_TOLERANCE_MINOR || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function orderTotalMinor(order) {
  // Works for two decimal currencies. Zero decimal currencies (JPY and friends)
  // have their own guide, since 50.00 is wrong for those.
  return Math.round(parseFloat(order.total) * 100);
}

export function capturedMinor(intent) {
  return intent.amount_received || 0;
}

export function toMajorStr(minor) {
  return (minor / 100).toFixed(2);
}

export function decide(order, intent, toleranceMinor = MISMATCH_TOLERANCE_MINOR) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a paid state"];
  if (!intent) return ["skip", "no Stripe PaymentIntent id on this order"];
  if (intent.status !== "succeeded" && intent.status !== "requires_capture") {
    return ["skip", "intent has no capture to compare yet"];
  }
  if ((intent.amount_capturable || 0) > 0) {
    return ["skip", "capture is still partial in progress, more may be captured"];
  }

  const orderMinor = orderTotalMinor(order);
  const chargedMinor = capturedMinor(intent);
  const drift = orderMinor - chargedMinor;

  if (Math.abs(drift) <= toleranceMinor) return ["ok", "order total matches what Stripe captured"];
  if (drift < 0) {
    return ["flag", `order total is lower than the Stripe charge (drift ${drift} minor units)`];
  }
  return [
    "fix",
    `only ${chargedMinor} of ${orderMinor} minor units was captured, order total should drop to match`,
  ];
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function* paidOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function applyFix(order, intent, reason) {
  const newTotal = toMajorStr(capturedMinor(intent));
  await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ total: newTotal }) });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Total corrected for a partial capture: ${reason}. Order total set to ` +
            `${newTotal} to match Stripe PaymentIntent ${intent.id}. Please review ` +
            `line items if this needs a refund too.`,
    }),
  });
}

async function flag(order, intent, reason) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Capture check failed: ${reason}. PaymentIntent ${intent.id}. ` +
            `Please review before shipping or refunding.`,
    }),
  });
}

export async function run() {
  let fixed = 0;
  let flagged = 0;
  for await (const order of paidOrders()) {
    const intent = await getIntent(intentIdOf(order));
    const [action, reason] = decide(order, intent);
    if (action === "skip") continue;
    if (action === "flag") {
      console.warn(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would flag" : "flagging"}`);
      if (!DRY_RUN) await flag(order, intent, reason);
      flagged++;
      continue;
    }
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
    if (!DRY_RUN) await applyFix(order, intent, reason);
    fixed++;
  }
  console.log(
    `Done. ${fixed} order(s) ${DRY_RUN ? "to fix" : "fixed"}, ` +
    `${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a real order total gets changed. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_partial_capture_decide.py
from sync_partial_capture import decide, intent_id_of, order_total_minor, to_major_str


def intent(**over):
    base = {"id": "pi_1", "status": "succeeded", "amount_received": 5000, "amount_capturable": 0}
    base.update(over)
    return base


def order(**over):
    base = {"status": "processing", "total": "80.00"}
    base.update(over)
    return base


def test_fix_when_captured_less_than_order_total():
    action, reason = decide(order(total="80.00"), intent(amount_received=5000))
    assert action == "fix"
    assert "5000" in reason


def test_ok_when_captured_matches_order_total():
    action, _ = decide(order(total="50.00"), intent(amount_received=5000))
    assert action == "ok"


def test_flag_when_order_total_lower_than_charge():
    action, reason = decide(order(total="40.00"), intent(amount_received=5000))
    assert action == "flag"
    assert "lower" in reason


def test_skip_when_capture_still_in_progress():
    action, reason = decide(order(), intent(amount_capturable=1500))
    assert action == "skip"
    assert "in progress" in reason
sync-partial-capture.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./sync-partial-capture.js";

const intent = (over = {}) => ({ id: "pi_1", status: "succeeded", amount_received: 5000, amount_capturable: 0, ...over });
const order = (over = {}) => ({ status: "processing", total: "80.00", ...over });

test("fix when captured less than order total", () => {
  const [action, reason] = decide(order({ total: "80.00" }), intent({ amount_received: 5000 }));
  assert.equal(action, "fix");
  assert.match(reason, /5000/);
});

test("ok when captured matches order total", () => {
  const [action] = decide(order({ total: "50.00" }), intent({ amount_received: 5000 }));
  assert.equal(action, "ok");
});

test("flag when order total lower than charge", () => {
  const [action, reason] = decide(order({ total: "40.00" }), intent({ amount_received: 5000 }));
  assert.equal(action, "flag");
  assert.match(reason, /lower/);
});

test("skip when capture still in progress", () => {
  const [action, reason] = decide(order(), intent({ amount_capturable: 1500 }));
  assert.equal(action, "skip");
  assert.match(reason, /in progress/);
});

Case studies

Split shipment

The store that shipped half and charged half

A furniture store authorized the full order at checkout but only captured payment for items as they shipped from the warehouse. When one item was discontinued mid backorder, the store captured for everything else and let the rest of the authorization expire. The orders kept their original, higher totals in WooCommerce.

Running the script in dry run surfaced eleven affected orders going back two weeks, each with the exact new total listed. Once the list was confirmed against the Stripe Dashboard, the team ran it for real and every order total lined up with what was actually charged.

Stock shortfall

The bookkeeper who could not make the numbers match

A monthly reconciliation kept coming up short, Stripe's payouts were a bit lower than what WooCommerce reported as total sales. The cause was a handful of orders where a warehouse pick error led to a partial capture that nobody recorded on the order.

A weekly scheduled run of the script now catches these within days instead of surfacing a month later in a spreadsheet, and each correction leaves an order note the bookkeeper can point to.

What good looks like

After this runs on a schedule, a partial capture stops being a silent gap between two systems. The order total tells the truth about what was paid, every correction has a note explaining why, and reconciling Stripe payouts against WooCommerce totals stops being a guessing game. Keep it running even for stores that rarely use manual capture, since the one time it happens is exactly when you will not notice on your own.

FAQ

Why does my WooCommerce order still show the full amount after a partial capture?

WooCommerce sets the order total when the order is placed and never checks back with Stripe once the charge is captured. If you or your fulfillment process later captures less than the full authorized amount, Stripe's PaymentIntent shows the real amount taken, but nothing tells WooCommerce to lower the order total. A script that compares the two and corrects the order fixes it.

Is it safe to change an order total with a script?

Yes, when the script only lowers the total to match a PaymentIntent that has finished capturing and whose amount_received is genuinely less than the order total. It skips orders that already match and flags the rare case where the order total is lower than the charge, since that points at a different problem. Start in dry run mode to review the list before it writes.

What if the order total is lower than what Stripe captured?

That is an overcharge, not a partial capture, and the script does not touch it automatically. It flags the order with a note instead, since fixing an overcharge usually means a refund, which needs a person to decide the right amount.

Related field notes

Citations

On the problem:

  1. Stripe docs: capturing a PaymentIntent for less than the authorized amount releases the remainder. docs.stripe.com/payments/place-a-hold-on-a-payment-method
  2. Stripe API reference: the PaymentIntent object, including amount_received and amount_capturable. docs.stripe.com/api/payment_intents/object
  3. WooCommerce docs: Stripe gateway settings, including authorize now and capture later. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: capturing a PaymentIntent, including the amount_to_capture parameter. docs.stripe.com/api/payment_intents/capture
  2. WooCommerce REST API: update an order's total and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. Stripe docs: working with amounts, minor units, and zero decimal currencies. docs.stripe.com/currencies

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your order totals?

If this saved you a confusing afternoon of reconciling Stripe payouts against WooCommerce reports, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes