The fee total is probably not wrong. Your Stripe fees CSV and your internal sales report are measuring different layers of the same payment flow, so a raw comparison turns ordinary processor costs into fake mismatches.
A sales report tracks orders and internal revenue logic. Stripe's fees export tracks fee events tied to charges, refunds, disputes, or other balance activity. Until the period, reference key, and amount basis match, you cannot tell whether fees are missing or whether the files were never comparable.
Diagnose the mismatch before you total anything
Most bad fee comparisons can be classified before you write a formula. The two files usually disagree because the same commercial event is stored in different shapes.
| What you see on screen | How the internal sales report stores it | How the Stripe fees CSV stores it | What happens when matched raw |
|---|---|---|---|
Order #10428 for 120.00 | One row keyed by order number, dated in local time | One fee row keyed by ch_... or another Stripe-side reference | No direct match even though the fee belongs to that sale |
Sale dated 2026-05-31 | Local sale date inside the month-end report | Fee incurred just after midnight UTC on 2026-06-01 | Same sale drops outside the filter and looks like a missing fee |
Sales report shows 97.00 net product revenue | Tax and shipping excluded from the internal number | Stripe fee assessed on the full customer charge | Effective fee rate looks too high because the denominator changed |
| Refunded order no longer appears in current-period sales | Original sale removed or netted down in the report | Original processing fee still sits against the charge history | Fee looks orphaned or overstated against current sales |
| Additional fee line appears with no obvious sale | Internal report has no bucket for cross-border, FX, or dispute-related costs | Stripe exports the fee as its own event | Total Stripe fees look inflated if you total every fee line together |
| Amounts look identical but still fail to match | Amount or key stored as text | Amount or key stored as a number or cleaned text | Exact lookups fail before reconciliation even starts |
Each row needs a different fix. Classify the mismatch first, then correct the comparison.
Define what each file is actually measuring
| File | Typical columns | What the file is really telling you |
|---|---|---|
| Internal sales report | Order ID, order date, gross sales, discounts, tax, shipping, net sales, maybe booked processor fee | What the business sold, billed, or recognized internally |
| Stripe fees CSV | incurred_by, incurred_by_type, fee_description, amount, currency, fee date, balance transaction reference | What Stripe charged as a fee, when it charged it, and what object caused it |
One sale can create more than one Stripe-side event. The original charge creates a processing fee. A later refund can change the economics of that order without restoring the original fee. A dispute can create a separate cost later.
That is why this article is not the same workflow as matching a Stripe payout CSV to your bank statement. A payout-to-bank match proves cash. A fees-to-sales comparison proves whether processor costs were captured correctly for the sales you are reviewing.
The practical question is which in-scope Stripe fees are already reflected internally and which are missing or understated. If the files cannot answer that, the problem is structure, not math.
Set the comparison boundary before you touch formulas
Do not total anything until both files use the same window and amount basis.
| Boundary check | Why it matters | What you need to standardize |
|---|---|---|
| Period basis | Sales reports often use local order date. Stripe fee exports can use UTC or fee-incurred date | Pick one reporting basis for both sides before filtering |
| Timezone | A late-night charge can move into the next UTC day | Convert Stripe-side dates to the same reporting timezone as your sales report |
| Currency | One side may be local currency while Stripe fee rows stay in settlement currency | Compare only rows in the same currency or split the report first |
| Amount basis | Internal report may exclude tax, shipping, or discounts while Stripe fee is assessed on the full charge | Decide whether the denominator is customer charge amount or an internal net-sales figure |
| Status scope | Voids, unpaid invoices, or uncaptured orders can sit in a sales report without a finalized Stripe fee | Limit the comparison to completed charge activity |
If every difference is off by one day, test the date logic first. That usually means a cutoff or timezone problem, not a missing fee. Why Stripe UTC timestamps mismatch bank statement CSV dates covers the same failure from the payout side, and the rule is the same here: compare local reporting dates to local reporting dates, not UTC raw values to local summaries.
Check the imported types before you trust the cells. Use TYPE() because it tells you directly whether Excel sees text or a number.
=TYPE(A2)on a date or amount should return1for a numeric Excel value.=TYPE(A2)returning2means the value is text and cannot be compared safely yet.
If an amount came in as text with commas, force it back to a number inline:
=VALUE(SUBSTITUTE(A2,",",""))If a reference is stored as a number on one side and text on the other, standardize it before the lookup:
=TEXT(A2,"0")If a date arrived as text and the format is ambiguous, extract the parts explicitly instead of hoping Excel guesses correctly. For MM/DD/YYYY text:
=DATE(RIGHT(B2,4),LEFT(B2,2),MID(B2,4,2))For DD/MM/YYYY text, swap the month and day positions:
=DATE(RIGHT(B2,4),MID(B2,4,2),LEFT(B2,2))Those checks do not solve the whole comparison. They only stop import damage from masquerading as a fee issue.
Build a bridge key, or admit you do not have one yet
Fee comparisons fail on reference before arithmetic. The internal sales report and the Stripe fees CSV often use different identifiers for the same transaction.
Use the strongest bridge available:
| Match option | Reliability | When to use it |
|---|---|---|
| Stripe charge ID or payment reference in both files | Highest | Best case. Fee row can be tied directly to the sale |
| Order ID carried into Stripe metadata and surfaced in an export | High | Good if your platform pushes the same order reference into Stripe |
| Exact amount + normalized local date + currency | Medium | Fallback only when no durable reference exists |
| Customer email, customer name, or rounded totals | Low | Manual review only, never primary matching logic |
If your internal sales report has only order numbers and your Stripe fees export has only Stripe charge IDs, those two files alone do not support a reliable fee-by-fee comparison. Pull a bridge export that maps the order to the Stripe charge. Without that bridge, the review is defensible only at the grouped-total level.
When the key exists but the types differ, fix the mismatch inside the lookup before creating helper columns. If the internal reference in A2 is text and the Stripe-side key column is numeric:
=XLOOKUP(VALUE(A2),Stripe!$F:$F,Stripe!$G:$G,"No match")If the internal reference is numeric and the Stripe-side key is text:
=XLOOKUP(TEXT(A2,"0"),Stripe!$F:$F,Stripe!$G:$G,"No match")Do not compare on gross amount alone unless the file is tiny and you are proving each row manually.
Separate fee types before you decide fees are missing
A Stripe fees CSV is not one clean line called "processing fee" repeated all month. If you total every fee row together and compare it to one processor-fee column in the sales report, you will almost always overstate the difference.
Group the fee rows first.
| Stripe fee line | Compare directly to current sales in scope? | Why |
|---|---|---|
| Processing fee on a captured charge | Yes | This is the core fee most teams expect to see against current sales |
| Fee impact tied to a later refund | Track separately from current sales total | The sale may have dropped out of the current report while the original fee economics still matter |
| Cross-border or currency-conversion cost | Separate bucket | It changes effective rate and is often invisible in internal sales summaries |
| Dispute or chargeback-related cost | Separate exception bucket | It is real, but it is not the same thing as standard processing cost |
| Non-payment product or account fees | Exclude from sales-fee comparison | These belong to a different cost analysis, not a per-sale fee test |
Split the file by what incurred the fee before you calculate anything.
Internal reports often book one processor-fee column based on an expected rate, while the Stripe export shows extra fee components that never made it into that estimate. The reverse can happen too when the files use different period bases. Both are scope problems before they are accounting problems.
Run the comparison that actually finds missing or understated fees
Once the period, timezone, amount basis, and bridge key are stable, the comparison becomes mechanical.
- Filter the Stripe fees CSV to the reporting window using the normalized local date, not the raw imported text.
- Remove fee lines that do not belong in the current sales-fee comparison.
- Map each remaining fee row to the strongest available bridge key.
- Aggregate the Stripe fee rows to the same grain as the internal sales report.
- Compare the internal booked fee to the Stripe total at that same grain.
- Classify every difference before you correct anything.
A useful output looks like this:
| Match key | Gross sale in report | Fee in internal report | Fee in Stripe CSV | Difference | What it usually means |
|---|---|---|---|---|---|
ORD-10428 | 120.00 | 3.78 | 3.78 | 0.00 | Correctly booked |
ORD-10429 | 240.00 | 5.20 | 6.10 | 0.90 | Internal report understated the actual fee |
ORD-10430 | 88.00 | 2.55 | 0.00 | 2.55 | Wrong period, wrong key, or missing Stripe-side row in scope |
ORD-09871 | 0.00 in current report | 0.00 | 3.40 | 3.40 | Fee tied to a sale that later dropped out or was refunded |
| No bridge key | 150.00 | 4.35 | 4.35 across two Stripe rows | 0.00 total, but unproven by row | Period total may be right even though transaction proof is weak |
This is the point where you can distinguish real failures from expected differences:
- A missing fee is a fee present in Stripe that should be in scope and has no corresponding amount in the internal report or books.
- An understated fee is a fee present in both places, but the internal amount is lower because it ignored part of the actual Stripe cost.
- A timing difference is a fee that belongs to the commercial activity you care about but falls into a different reporting window under the chosen basis.
- An unproven fee is a fee that looks right in total but cannot be tied back to a sale because the bridge key does not exist.
If your internal sales report does not carry a fee column, use Stripe's actual fee data to identify what should have been booked, then measure the gap against whatever fee expense or accrual exists elsewhere in your records.
Do not use percentage alone as proof. Use the blended rate only as a reasonableness check after the row-level or grouped comparison is classified.
What a finished review should prove
By the end of the review, you should be able to answer more than "the totals are close."
| Question | What the finished comparison should tell you |
|---|---|
| Which sales are in scope? | Exact period, timezone, and currency boundary used |
| Which Stripe fee rows belong to those sales? | Processing-fee set after non-comparable fee lines were separated |
| Which fees already exist in the internal report? | Matched amount and matched row count or grouped total |
| Which fees are missing or understated? | Clear exception list with amount and likely reason |
| Which rows still cannot be proven? | Transactions that need a bridge export or a scope correction |
If the files do not share a usable key, more filtering will not solve it. Pull the bridge export or accept that the comparison is only defensible at the grouped-total level.
When the file-by-file routine becomes the real cost
This becomes expensive when every close requires the same routine: normalize dates, repair types, separate fee classes, rebuild the match key, aggregate the Stripe rows, and explain the same exceptions again.
