Resource library

QA How-To

Test Payment Partial Capture and Refunds (2026)

Learn to test payment partial capture and refunds with Stripe test mode, Playwright assertions, idempotent retries, negative cases, and webhook checks.

24 min read | 3,223 words

TL;DR

To test payment partial capture and refunds, assert the complete money ledger, not only HTTP success: authorized amount, amount captured, amount released, cumulative refunds, and remaining refundable balance. Add boundary failures, idempotent retries, and duplicate webhook delivery so the suite covers the paths that cause real reconciliation defects.

Key Takeaways

  • Create a manual-capture PaymentIntent and assert the authorized amount before exercising capture behavior.
  • Check money in integer minor units and reconcile authorized, captured, uncaptured, and refunded amounts after every mutation.
  • Treat partial capture and partial refund as different state transitions with different provider consequences.
  • Verify negative boundaries such as over-capture, over-refund, duplicate final capture, and refund before capture.
  • Reuse one idempotency key for the same refund retry and prove that Stripe creates only one Refund object.
  • Validate webhook signatures and deduplicate event IDs because delivery can be repeated or reordered.
  • Run destructive payment tests only with test credentials and uniquely created sandbox objects.

To test payment partial capture and refunds reliably, create a fresh manual-capture payment in a processor sandbox, capture less than the authorized amount, issue one or more refunds, and assert every money field after each transition. A passing status code is not enough. Your test must prove that the captured amount, released authorization, cumulative refund total, and remaining refundable balance agree.

This tutorial builds that evidence against Stripe test mode with Playwright's API-focused test runner and the official Stripe Node SDK. It keeps each scenario financially isolated and uses integer cents throughout. The suite complements broader API idempotency testing by applying retry rules to a payment mutation where duplication has a direct monetary consequence.

The examples use only sk_test_ credentials. They create PaymentIntents and Refunds in your Stripe sandbox, never touch live cardholder funds, and refuse to start when the key does not have the test prefix.

What You Will Build

You will build a small JavaScript test project that can:

  • Authorize USD 100.00, capture USD 65.00, and prove that the unused USD 35.00 is no longer capturable.
  • Capture USD 90.00, refund it in two installments, and reconcile the charge after each refund.
  • Reject capture and refund amounts that cross the processor's permitted boundary.
  • Retry a partial refund with one idempotency key and verify that only one Refund object exists.
  • Verify a signed refund.created webhook and ignore a repeated event ID in a local ledger.
  • Produce readable Playwright output that identifies the payment object involved in a failure.

The central accounting equation is simple but powerful: captured = refunded + remaining refundable. For a standard one-shot partial capture, the original authorization also satisfies authorized = captured + released. Keep these equations visible in assertions because they catch defects that a status-only check misses.

Prerequisites

Use these exact versions for the walkthrough:

  • Node.js 24.18.0 LTS
  • npm 11.x, bundled with or installed for Node.js 24
  • @playwright/test 1.62.0
  • stripe 22.1.1
  • dotenv 17.4.2
  • A Stripe account with a restricted or standard test-mode secret key that begins with sk_test_

You do not need browser binaries because these tests use the Playwright test runner without launching a browser. Obtain the secret from the Stripe Dashboard's test environment, store it locally, and never paste it into source control or CI logs.

Confirm your runtime before creating the project:

node --version
npm --version

Expected Node output is v24.18.0. An npm 11 release is appropriate. If your organization pins another supported LTS line, keep the dependency versions unchanged and validate the suite there as a separate compatibility job.

Step 1: Bootstrap the Payment API Test Project

Create an empty directory and install exact dependency versions. The --save-exact flag prevents a fresh checkout from silently selecting a newer package release.

mkdir payment-capture-refund-tests
cd payment-capture-refund-tests
npm init -y
npm install --save-exact @playwright/test@1.62.0 stripe@22.1.1 dotenv@17.4.2
mkdir -p tests/helpers

Replace package.json with this complete project definition:

{
  "name": "payment-capture-refund-tests",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "playwright test",
    "test:payments": "playwright test tests/payment"
  },
  "dependencies": {
    "@playwright/test": "1.62.0",
    "dotenv": "17.4.2",
    "stripe": "22.1.1"
  }
}

Add .env locally and put .env in .gitignore:

STRIPE_SECRET_KEY=sk_test_replace_with_your_test_key
STRIPE_WEBHOOK_SECRET=whsec_local_payment_test_secret
node_modules/
test-results/
playwright-report/
.env

The webhook secret in this tutorial is local test data used to sign a constructed event. The API key is real sandbox authentication and must come from your Stripe test environment.

Verify: inspect the installed dependency tree.

npm ls @playwright/test stripe dotenv

The command should list @playwright/test@1.62.0, stripe@22.1.1, and dotenv@17.4.2 without invalid or missing markers.

Step 2: Configure a Test-Mode Guard and Payment Helpers

Create playwright.config.js. The guard stops the entire run before a live key can create a payment. Serial execution also makes sandbox records and failure logs easier to follow while you learn the flow.

// playwright.config.js
import 'dotenv/config';
import { defineConfig } from '@playwright/test';

const secretKey = process.env.STRIPE_SECRET_KEY ?? '';
if (!secretKey.startsWith('sk_test_')) {
  throw new Error('STRIPE_SECRET_KEY must be a Stripe test-mode key');
}

export default defineConfig({
  testDir: './tests',
  timeout: 60_000,
  expect: { timeout: 10_000 },
  workers: 1,
  reporter: [['list']],
});

Now create tests/helpers/stripe-payments.js. Every later test imports these exact names. pm_card_visa is Stripe's reusable test PaymentMethod, so no raw card number enters the test suite.

// tests/helpers/stripe-payments.js
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  maxNetworkRetries: 0,
});

export async function authorizePayment(amount, currency = 'usd') {
  return stripe.paymentIntents.create({
    amount,
    currency,
    payment_method: 'pm_card_visa',
    payment_method_types: ['card'],
    capture_method: 'manual',
    confirm: true,
    metadata: { suite: 'partial-capture-refunds' },
  });
}

export async function captureAmount(paymentIntentId, amount, options = {}) {
  return stripe.paymentIntents.capture(
    paymentIntentId,
    { amount_to_capture: amount },
    options,
  );
}

export async function retrieveWithCharge(paymentIntentId) {
  return stripe.paymentIntents.retrieve(paymentIntentId, {
    expand: ['latest_charge'],
  });
}

export async function cancelIfCapturable(paymentIntentId) {
  if (!paymentIntentId) return;
  const payment = await stripe.paymentIntents.retrieve(paymentIntentId);
  if (payment.status === 'requires_capture') {
    await stripe.paymentIntents.cancel(paymentIntentId);
  }
}

maxNetworkRetries: 0 is deliberate. The SDK will not hide a network retry from a test that is meant to examine retry behavior. Production clients may use a different setting. The helper cleanup cancels an authorization only when it is still capturable, which releases the hold through the supported PaymentIntent operation.

Verify: load the configuration and list discovered tests. At this moment zero tests is correct, but the command must pass the test-key guard.

npx playwright test --list

If the key is valid, Playwright prints Total: 0 tests in 0 files. A missing or live key stops with the explicit guard message.

Step 3: test payment partial capture and refunds with State Assertions

Start with the partial capture itself. Create tests/payment/partial-capture.spec.js. The test authorizes 10,000 cents, confirms the intermediate requires_capture state, captures 6,500 cents, then retrieves the expanded Charge for a second view of the ledger.

// tests/payment/partial-capture.spec.js
import { test, expect } from '@playwright/test';
import {
  authorizePayment,
  cancelIfCapturable,
  captureAmount,
  retrieveWithCharge,
} from '../helpers/stripe-payments.js';

let paymentIntentId;

test.afterEach(async () => {
  await cancelIfCapturable(paymentIntentId);
  paymentIntentId = undefined;
});

test('captures 6500 cents and releases the uncaptured authorization', async () => {
  const authorized = await authorizePayment(10_000);
  paymentIntentId = authorized.id;

  expect(authorized.status).toBe('requires_capture');
  expect(authorized.amount).toBe(10_000);
  expect(authorized.amount_capturable).toBe(10_000);
  expect(authorized.amount_received).toBe(0);

  const captured = await captureAmount(authorized.id, 6_500);
  expect(captured.status).toBe('succeeded');
  expect(captured.amount_received).toBe(6_500);
  expect(captured.amount_capturable).toBe(0);

  const payment = await retrieveWithCharge(authorized.id);
  const charge = payment.latest_charge;
  expect(typeof charge).toBe('object');
  expect(charge.amount).toBe(10_000);
  expect(charge.amount_captured).toBe(6_500);
  expect(charge.amount_refunded).toBe(0);

  const released = authorized.amount - charge.amount_captured;
  expect(released).toBe(3_500);
});

For ordinary card payments, a partial capture is final: the unused authorization is released and the PaymentIntent becomes succeeded. Do not assert that amount_capturable equals 3,500 after this operation. That expectation confuses standard partial capture with an eligible multicapture integration, which has extra processor and payment-method requirements.

The Charge retains the original amount and separately reports amount_captured. That distinction is why the test reads both PaymentIntent and Charge. One object explains the workflow state, while the other makes refund reconciliation convenient.

Verify: run only the partial capture scenario.

npx playwright test tests/payment/partial-capture.spec.js

Expected output contains one passed test. In the Stripe test Dashboard, the PaymentIntent should show a USD 100.00 authorization with USD 65.00 captured.

Step 4: Test Partial and Full Refund Accumulation

A refund can be partial more than once until the captured balance is exhausted. Create tests/payment/refunds.spec.js and prove the intermediate and final totals instead of trusting each Refund object in isolation.

// tests/payment/refunds.spec.js
import { test, expect } from '@playwright/test';
import {
  authorizePayment,
  cancelIfCapturable,
  captureAmount,
  retrieveWithCharge,
  stripe,
} from '../helpers/stripe-payments.js';

let paymentIntentId;

test.afterEach(async () => {
  await cancelIfCapturable(paymentIntentId);
  paymentIntentId = undefined;
});

test('accumulates two refunds up to the captured amount', async () => {
  const authorized = await authorizePayment(12_000);
  paymentIntentId = authorized.id;
  await captureAmount(authorized.id, 9_000);

  const firstRefund = await stripe.refunds.create({
    payment_intent: authorized.id,
    amount: 2_500,
    reason: 'requested_by_customer',
  });
  expect(firstRefund.amount).toBe(2_500);
  expect(firstRefund.status).toBe('succeeded');

  let payment = await retrieveWithCharge(authorized.id);
  let charge = payment.latest_charge;
  expect(charge.amount_captured).toBe(9_000);
  expect(charge.amount_refunded).toBe(2_500);
  expect(charge.refunded).toBe(false);
  expect(charge.amount_captured - charge.amount_refunded).toBe(6_500);

  const secondRefund = await stripe.refunds.create({
    payment_intent: authorized.id,
    amount: 6_500,
    reason: 'requested_by_customer',
  });
  expect(secondRefund.amount).toBe(6_500);
  expect(secondRefund.status).toBe('succeeded');

  payment = await retrieveWithCharge(authorized.id);
  charge = payment.latest_charge;
  expect(charge.amount_refunded).toBe(9_000);
  expect(charge.refunded).toBe(true);
  expect(charge.amount_captured - charge.amount_refunded).toBe(0);
});

The two Refund objects describe separate adjustments, while charge.amount_refunded is cumulative. charge.refunded does not mean that any refund exists. It becomes true when the captured charge has been refunded completely. Also notice that the refundable ceiling is 9,000 cents, not the original 12,000-cent authorization. The released 3,000 cents was never captured and cannot later be refunded.

This test uses immediate success behavior in Stripe's card sandbox. In a provider or payment method that returns pending, assert the documented initial state, poll the Refund by ID with a bounded deadline, and finish only at succeeded, failed, or canceled. Never replace that lifecycle with an unconditional sleep.

Verify: execute the refund file and inspect the two refund entries attached to one PaymentIntent.

npx playwright test tests/payment/refunds.spec.js

The runner should report one pass. The Dashboard should show refund amounts of USD 25.00 and USD 65.00 against the USD 90.00 captured amount.

Step 5: Enforce Capture and Refund Boundaries

Happy-path accounting does not prove that invalid money movement is blocked. Create tests/payment/boundaries.spec.js with independent objects so one expected error cannot contaminate another scenario.

// tests/payment/boundaries.spec.js
import { test, expect } from '@playwright/test';
import {
  authorizePayment,
  cancelIfCapturable,
  captureAmount,
  stripe,
} from '../helpers/stripe-payments.js';

const capturableIds = new Set();

test.afterEach(async () => {
  for (const id of capturableIds) await cancelIfCapturable(id);
  capturableIds.clear();
});

test('rejects capture above the authorized amount', async () => {
  const payment = await authorizePayment(5_000);
  capturableIds.add(payment.id);

  await expect(captureAmount(payment.id, 5_001)).rejects.toMatchObject({
    type: 'StripeInvalidRequestError',
  });

  const unchanged = await stripe.paymentIntents.retrieve(payment.id);
  expect(unchanged.status).toBe('requires_capture');
  expect(unchanged.amount_capturable).toBe(5_000);
});

test('rejects a refund above the unrefunded captured balance', async () => {
  const payment = await authorizePayment(4_000);
  capturableIds.add(payment.id);
  await captureAmount(payment.id, 3_000);
  await stripe.refunds.create({
    payment_intent: payment.id,
    amount: 3_000,
  });

  await expect(
    stripe.refunds.create({ payment_intent: payment.id, amount: 1 }),
  ).rejects.toMatchObject({ type: 'StripeInvalidRequestError' });
});

Each error assertion is paired with a financial postcondition. The first case retrieves the PaymentIntent and proves the failed over-capture did not reduce the authorization. The second establishes a zero remaining balance before attempting one extra cent. In your own payment façade, also assert the stable HTTP status, machine-readable error code, and absence of new ledger entries. The API error handling and negative testing guide shows how to keep transport errors separate from business-state evidence.

Extend this file with zero and negative amounts, unsupported currencies, capture after cancellation, a second final capture, refund before capture, unknown PaymentIntent IDs, and cross-account access. Choose assertions from the provider contract. Avoid depending on full human-readable messages because wording can change without altering behavior.

Verify: run the boundary tests.

npx playwright test tests/payment/boundaries.spec.js

Expected output is two passed tests. A rejected Stripe promise counts as the intended result because Playwright's rejects assertion verifies the error type.

Step 6: Prove a Partial Refund Retry Is Idempotent

A client can lose the first refund response and send the request again. Reuse the same idempotency key for that logical refund. Stripe accepts request options as the final SDK argument, so the key belongs outside the Refund parameters.

Create tests/payment/refund-idempotency.spec.js:

// tests/payment/refund-idempotency.spec.js
import { test, expect } from '@playwright/test';
import {
  authorizePayment,
  cancelIfCapturable,
  captureAmount,
  stripe,
} from '../helpers/stripe-payments.js';

let paymentIntentId;

test.afterEach(async () => {
  await cancelIfCapturable(paymentIntentId);
  paymentIntentId = undefined;
});

test('returns one Refund object when the same request is retried', async () => {
  const payment = await authorizePayment(6_000);
  paymentIntentId = payment.id;
  await captureAmount(payment.id, 5_000);

  const params = { payment_intent: payment.id, amount: 1_000 };
  const options = { idempotencyKey: `refund-${payment.id}-1000` };

  const first = await stripe.refunds.create(params, options);
  const retry = await stripe.refunds.create(params, options);

  expect(retry.id).toBe(first.id);
  expect(retry.amount).toBe(1_000);

  const refunds = await stripe.refunds.list({
    payment_intent: payment.id,
    limit: 10,
  });
  expect(refunds.data).toHaveLength(1);
  expect(refunds.data[0].id).toBe(first.id);
});

The strongest assertion is not that both calls return success. It is that both responses identify the same Refund and that a filtered list contains one object. A new idempotency key would describe a new mutation and could legitimately create another refund, so generate the key once at the business-operation boundary and persist it across transport retries.

Do not send the two calls concurrently with the same key in a basic CI test. A processor may report that the key is already executing, which is a valid transient condition. Test simultaneous delivery separately with an explicit retry policy and a final ledger assertion. For deeper request-key coverage, use idempotency and retries in API tests.

Verify: run the idempotency case.

npx playwright test tests/payment/refund-idempotency.spec.js

The test passes only when the retry returns the original Refund ID and the PaymentIntent has exactly one refund record.

Step 7: Verify Signed Refund Webhooks and Duplicate Delivery

API responses and webhooks are two observations of the same business operation. Your consumer must validate the signature before parsing the event, then deduplicate by event ID. Create tests/helpers/refund-ledger.js as a deliberately small in-memory consumer used only for this isolated test.

// tests/helpers/refund-ledger.js
export class RefundLedger {
  constructor() {
    this.seenEventIds = new Set();
    this.refundedByPayment = new Map();
  }

  apply(event) {
    if (this.seenEventIds.has(event.id)) return false;
    this.seenEventIds.add(event.id);

    if (event.type === 'refund.created') {
      const refund = event.data.object;
      const current = this.refundedByPayment.get(refund.payment_intent) ?? 0;
      this.refundedByPayment.set(refund.payment_intent, current + refund.amount);
    }
    return true;
  }

  totalFor(paymentIntentId) {
    return this.refundedByPayment.get(paymentIntentId) ?? 0;
  }
}

Create tests/payment/refund-webhook.spec.js. The official SDK creates a valid test signature and verifies it with constructEvent. The payload is local fixture data, so this test is deterministic and does not wait for public webhook delivery.

// tests/payment/refund-webhook.spec.js
import { test, expect } from '@playwright/test';
import { stripe } from '../helpers/stripe-payments.js';
import { RefundLedger } from '../helpers/refund-ledger.js';

test('verifies and deduplicates a refund.created event', async () => {
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  const payload = JSON.stringify({
    id: 'evt_refund_created_001',
    object: 'event',
    type: 'refund.created',
    data: {
      object: {
        id: 're_test_001',
        object: 'refund',
        amount: 2_500,
        currency: 'usd',
        payment_intent: 'pi_test_001',
        status: 'succeeded',
      },
    },
  });

  const signature = stripe.webhooks.generateTestHeaderString({
    payload,
    secret,
  });
  const event = stripe.webhooks.constructEvent(payload, signature, secret);

  const ledger = new RefundLedger();
  expect(ledger.apply(event)).toBe(true);
  expect(ledger.apply(event)).toBe(false);
  expect(ledger.totalFor('pi_test_001')).toBe(2_500);
});

Production code must pass the raw request body to signature verification. Parsing JSON and serializing it again can change bytes and invalidate the signature. A durable consumer should place the event ID under a database uniqueness constraint in the same transaction as the refund projection update. An in-memory set is suitable only for this unit boundary.

Next, replay real sandbox events through Stripe CLI or your approved webhook relay in an integration environment. Exercise invalid signatures, an old timestamp, duplicate IDs, two distinct refund events, and reversed delivery order. Use step-by-step webhook signature verification for the HTTP handler and webhook duplicate and ordering validation for persistence scenarios.

Verify: run the local signed-event test.

npx playwright test tests/payment/refund-webhook.spec.js

Expected output is one passed test with no network call. Change one character in payload after generating the signature to confirm that constructEvent rejects the tampered body.

Step 8: run test payment partial capture and refunds as One Suite

Run every file through the named script:

npm run test:payments

The expected total is six tests: one partial capture, one refund accumulation, two boundary cases, one idempotent refund, and one webhook case. The first five communicate with Stripe test mode. The webhook case is local. A complete pass demonstrates the following transition set:

Scenario Starting state Operation Required evidence
Partial capture requires_capture, 10,000 capturable Capture 6,500 succeeded, 6,500 received, zero capturable
First refund 9,000 captured Refund 2,500 2,500 cumulative, 6,500 still refundable
Final refund 6,500 refundable Refund 6,500 9,000 cumulative, Charge fully refunded
Over-capture 5,000 capturable Capture 5,001 Typed rejection, authorization unchanged
Over-refund Zero refundable Refund 1 Typed rejection, no extra Refund
Retry One 1,000 refund request Repeat same key and body Same Refund ID, one list record
Duplicate webhook One verified event Apply same event twice Ledger changes once

Use a unique PaymentIntent per test as the suite does here. Shared payment fixtures create ordering dependencies and make financial failures difficult to diagnose. In CI, inject the test key from the secret store, keep workers at one until account rate limits and data isolation are understood, and retain PaymentIntent IDs in the Playwright trace or a sanitized attachment. Never record the secret key.

Verify: ask Playwright to list the same suite without executing it.

npx playwright test tests/payment --list

The list should contain the same six test titles. A changed count tells you that a file was skipped, renamed outside the configured directory, or accidentally filtered.

How to Design a Complete Payment Test Matrix

The tutorial proves the critical backbone, but a production gateway normally supports more combinations. Build a matrix around money state, not around endpoint count. Each row should name the authorized amount, capture operation, released amount, refund sequence, final balance, expected events, and retry policy.

Dimension High-value cases Why it matters
Amount Minimum unit, normal amount, provider maximum boundary Finds rounding, validation, and overflow defects
Currency Two-decimal and zero-decimal currencies Prevents confusing major units with minor units
Capture Full, partial, expired, canceled, repeated final call Covers the authorization lifecycle
Refund One partial, several partials, full, one unit over balance Proves cumulative accounting
Timing Immediate refund and refund after settlement transition Exposes provider-state assumptions
Retry Lost response, same key, changed body with same key Protects against duplicate money movement
Events Duplicate, late, invalid signature, reversed order Protects projections and customer notifications
Access Wrong merchant, wrong tenant, revoked credential Prevents cross-account mutation

Zero-decimal currencies deserve their own data set. For JPY, an amount of 500 means JPY 500, not JPY 5.00. Do not create a universal helper that multiplies every display value by 100. Store currency metadata with the test case and convert explicitly at the boundary. The API test data management guide can help isolate currencies, accounts, and provider objects without reusing mutable fixtures.

Also separate provider contract tests from your service tests. The suite above proves Stripe behavior and your assumptions. If your application exposes /payments/{id}/capture, add tests that verify your HTTP schema, authorization, database ledger, provider request, and response mapping. Stub provider failures in component tests, then keep a narrow sandbox suite for real integration confidence.

Interview Questions and Answers

Q: What is the difference between partial authorization and partial capture?

Partial authorization means the issuer approves less than the requested authorization, often because available funds are insufficient. Partial capture means the merchant deliberately settles less than an amount that was authorized. The test preconditions and permitted ceilings differ, so I never use the terms interchangeably.

Q: Which fields do you assert after a partial capture?

I assert the original requested amount, amount_received, amount_capturable, PaymentIntent status, Charge amount_captured, and the calculated released amount. I also confirm that a second final capture is rejected for a standard one-shot card flow. This proves both workflow state and money state.

Q: How do you calculate the remaining refundable amount?

I calculate amount_captured - amount_refunded from the authoritative charge or ledger. I do not subtract refunds from the original authorization because uncaptured money was released, not refunded. After each mutation, I compare the calculated result with the application's displayed or stored balance.

Q: How do you test refund idempotency?

I create one refund request and one stable idempotency key, send it twice, and assert that both responses identify the same Refund. Then I query refunds for the PaymentIntent and prove there is one record and one cumulative balance change. Response success alone would not expose a duplicate adjustment.

Q: Why should webhook tests include duplicate delivery?

Webhook systems commonly deliver at least once, so a successful handler can receive the same event again. I verify the signature, claim the event ID atomically, and update the local ledger only for the first claim. The test applies one signed event twice and expects one financial projection change.

Q: What negative cases are essential for capture and refund APIs?

I cover amounts above the authorized or refundable ceiling, zero and negative values, wrong currency, capture after cancellation or expiry, repeated final capture, refund before capture, unknown IDs, and cross-merchant access. Each error check includes a postcondition that the financial state did not change.

Troubleshooting

Problem: STRIPE_SECRET_KEY must be a Stripe test-mode key -> Put an active sk_test_ key in the project-root .env file. Confirm that the command runs from the same directory as playwright.config.js, and do not weaken the guard to accept sk_live_.

Problem: The authorized PaymentIntent does not reach requires_capture -> Confirm capture_method: 'manual', confirm: true, payment_method: 'pm_card_visa', and payment_method_types: ['card'] are present. If your account applies a payment-method configuration that changes confirmation behavior, retrieve the PaymentIntent and inspect last_payment_error and next_action before changing assertions.

Problem: The second capture fails after a partial capture -> This is expected for the standard one-shot card flow used here. A partial capture releases the unused authorization. Multicapture is a separate capability with eligibility and parameter requirements, so create a dedicated suite only after it is enabled and documented for your integration.

Problem: constructEvent reports a signature mismatch -> Pass the exact raw payload bytes used to create the signature and the same webhook secret. In an HTTP server, capture the raw body before JSON parsing. Do not stringify an already parsed object and expect its byte sequence to match.

Problem: A refund assertion occasionally sees pending -> Model the payment method's asynchronous lifecycle. Poll stripe.refunds.retrieve(refund.id) with a fixed deadline and interval, accept documented intermediate states, and fail with the final Refund object. Do not add a large fixed sleep or assume all payment methods settle like the Visa test method.

Problem: Cleanup returns an error for a completed PaymentIntent -> Cancel only objects whose current status is requires_capture. A succeeded PaymentIntent is no longer cancelable, and captured money must be handled with a Refund. Keep cleanup state-aware as cancelIfCapturable does.

Best Practices

  • Represent amounts as integer minor units and pair each amount with its currency.
  • Create a new PaymentIntent for every test so retries are the only intentional duplicates.
  • Assert provider state, application ledger state, and emitted event state for critical flows.
  • Use test-mode credentials with the least access needed, supplied by a secret manager in CI.
  • Keep one idempotency key bound to one logical operation and one immutable request body.
  • Record PaymentIntent, Charge, and Refund IDs in sanitized failure diagnostics.
  • Poll asynchronous states with a deadline and useful last-response output.
  • Test authorization expiry through supported test clocks or provider fixtures, not multi-day sleeps.
  • Avoid asserting mutable message text when typed errors and machine codes are available.
  • Reconcile cumulative totals after every refund instead of checking only the newest object.

Where To Go Next

Move the same accounting invariants behind your application's public payment API. Add schema checks with OpenAPI contract testing in Playwright and TypeScript, then verify that your service maps provider failures without losing the original error identity. If refunds are processed asynchronously, expand the webhook test into end-to-end webhook testing with a real receiver and durable event table.

For team practice, turn the matrix into scenario prompts from API testing scenario-based interview questions. Ask reviewers to explain why authorized, captured, refunded, and released amounts are distinct. That conversation catches vague requirements before code encodes the wrong ledger.

Finally, add your application's order, inventory, and notification postconditions. Payment correctness is not complete when Stripe is correct but the order remains unpaid, the customer receives two emails, or the internal balance uses the authorization instead of the capture.

Conclusion

A credible suite to test payment partial capture and refunds follows the money across every state transition. It proves how much was authorized, captured, released, refunded, and left refundable, then demonstrates that invalid boundaries and repeated delivery cannot move the balance twice.

Start with the six runnable tests in this guide. Once they are stable in Stripe test mode, apply the same invariants to your service boundary, database ledger, event consumer, and customer-visible order state.

Interview Questions and Answers

How would you test partial capture for a payment API?

I would authorize a known amount with manual capture, confirm the capturable state, and capture a smaller integer amount. Then I would assert the PaymentIntent status, received and capturable amounts, the Charge's captured amount, and the released difference. I would also prove that a repeated final capture cannot collect the remainder in a standard one-shot flow.

What accounting invariant do you use for refunds?

The core invariant is captured amount equals cumulative refunded amount plus remaining refundable amount. I recompute it after every refund from the authoritative ledger or Charge. The original authorization is not the refund ceiling because uncaptured funds were never collected.

How do you verify two partial refunds are correct?

I assert each Refund's amount and identity, retrieve the Charge after each operation, and compare its cumulative refunded amount with the sum of successful refunds. After the final installment reaches the captured amount, the remaining refundable balance must be zero and the Charge must be fully refunded.

How do you test an over-refund safely?

I create a sandbox payment, capture a known amount, and refund that entire captured balance. I then request one additional minor unit and expect a typed business rejection. Finally, I retrieve refund records and the charge to prove the failed request created no adjustment.

Why is an idempotency key important for refund APIs?

A timeout can leave the client unsure whether the refund was created. Repeating the same logical request with the same key lets the provider return the original outcome instead of creating another refund. I verify identical Refund IDs and one durable refund record, not merely two successful responses.

How do you handle asynchronous refund status in automation?

I accept documented intermediate states and poll the Refund resource by ID until a terminal state or deadline. The failure output includes the final object and elapsed time. Fixed sleeps are unsuitable because they can be both flaky and unnecessarily slow.

What webhook risks accompany partial refunds?

Separate refund events may arrive late, more than once, or in a different order than the application expects. The consumer must verify the raw-body signature, deduplicate event IDs atomically, and calculate state from authoritative data or monotonic ledger entries. Tests should cover duplicate and reordered delivery.

Frequently Asked Questions

How do you test a partial payment capture?

Create and confirm a manual-capture payment, assert that the full amount is capturable, then capture a smaller amount. Verify the payment succeeded, the captured amount equals the request, the remaining amount is no longer capturable for a standard one-shot flow, and the released amount equals authorization minus capture.

What is the difference between a partial capture and a partial refund?

A partial capture settles less than the authorized amount and releases the unused authorization. A partial refund returns part of money that was already captured. The refund ceiling therefore comes from captured money, not the original authorization.

Can a payment have multiple partial refunds?

Yes, Stripe can create multiple partial refunds until their cumulative amount reaches the captured and unrefunded balance. Test each Refund object and the Charge's cumulative `amount_refunded`. Once the captured amount is fully refunded, another refund must be rejected.

Can you capture the rest after a partial capture?

For the standard card flow in this tutorial, no. A partial capture is final and releases the unused authorization. Eligible multicapture integrations follow a different contract and require their own capability-aware tests.

How do you prevent duplicate refunds during retries?

Generate one idempotency key for the logical refund and reuse that key with the unchanged request on every retry. Assert that responses return the same Refund ID, then query the payment's refunds and cumulative amount to prove only one adjustment occurred.

Should payment tests use decimal amounts?

Use integer minor units at the API boundary, such as 2500 for USD 25.00. Keep the currency beside the amount because some currencies have zero decimal places. Avoid binary floating-point arithmetic for financial assertions.

How should refund webhooks be tested?

Verify the signature against the raw body, validate the event type and object, and deduplicate by event ID in durable storage. Test duplicate delivery, invalid signatures, multiple partial refunds, delayed events, and events arriving in a different order from API responses.

Related Guides