Merchgain
EN
Available now
Next, in this order
  • DeutschDE
  • EspañolES
  • FrançaisFR
  • ItalianoIT
  • PortuguêsPT
  • NederlandsNL

A language shows up here once its pages are actually written. Nothing on this site is machine-translated.

Install freeInstall

Your costs are already there — in the wrong field

You already did the work. The values sit in a field WooCommerce core knows nothing about, so the report shows a gap where your data is. This page moves them across — after showing you exactly what it would do.

Updated 2026-09-24

If the missing cost warning told you that some of your products keep a cost in another plugin, this is the page it was pointing at. Nothing here is specific to Merchgain: the script writes into the field WooCommerce core provides, so it works the same whether you keep our plugin or not.

This page assumes the Cost of Goods Sold feature is already switched on. If it is not, turn it on first — while the switch is off, values written into the core field never reach a single order.

Moving costs fixes the future, not the past. WooCommerce writes the cost onto the order line at the moment of sale, so orders placed before the move stay without a cost forever. There is no backfill and that is not an oversight. It is a reason to do this this week rather than next quarter.

Which fields the script reads

Where the cost is keptPlugin, by its own name
_alg_wc_cog_costCost of Goods: Product Cost & Profit Calculator for WooCommerce (WPFactory)
_wc_cog_costWooCommerce Cost of Goods (SkyVerge, sold on woocommerce.com)
_wcj_purchase_priceBooster for WooCommerce — its Cost of Goods module
_izettle_cost_priceBjornTech PayPal POS integration for WooCommerce
_ni_cost_goodsNi Cost of Goods for WooCommerce
cost_of_goodsCost Of Goods For WooCommerce
table atum_product_data, column purchase_priceATUM Inventory Management for WooCommerce

These places were found in those plugins’ own code — at the line where each one saves the value, not in a constant, a form label or a changelog. That distinction is not pedantry, and it has caught this page more than once. The WPFactory plugin announced a “prefix updated to wpfcogs” release, which changed the prefix of its functions and options and not the meta key. ATUM names its form field _purchase_price, yet since version 1.5.0 (December 2018) it keeps the value in a table of its own. A script written from either would look for a field that is not there and report a clean zero. The one exception is WooCommerce Cost of Goods: it is a paid extension with no public code, so its key comes from its own documentation, confirmed by the plugins that import from it.

Correction, 24 September 2026. Until today this script read ATUM’s _purchase_price meta field. ATUM has not written there since 1.5.0: in a shop that ran ATUM earlier, those rows are copies frozen on the day ATUM moved its data into its own table, and in a newer shop they do not exist at all. If you ran the earlier script on an ATUM shop and it moved anything, compare those costs with ATUM’s purchase prices before you rely on the report.

Two of them need a word of their own. Booster can add “extra expenses” and custom cost fields on top of the purchase price; the script moves the purchase price alone, because shipping and similar extras are not cost of goods — if you do count them, add them by hand. WooCommerce Cost of Goods from version 2.15.0 copies its costs into the core field by itself, according to its changelog; in a shop running it the script will mostly report costs that are already there.

If your costs live somewhere else, the shape of the script is still right; add your key to the list at the top and check what the value means before you run it — net or gross, per item or per line. A cost field that turns out to have been storing gross prices is worse than no cost field.

The script

It reads, decides, and writes nothing until you change the first line. Save it as migrate-costs.php and run it with WP-CLI.

<?php
/**
 * Moves costs kept by another plugin into WooCommerce's native cost field.
 * Run with:  wp eval-file migrate-costs.php
 */

$DRY_RUN = true;   // <- switch to false only after a successful trial

// Fields kept in post meta: meta key => plugin.
$KEYS = array(
    '_alg_wc_cog_cost'    => 'Cost of Goods: Product Cost & Profit Calculator for WooCommerce',
    '_wc_cog_cost'        => 'WooCommerce Cost of Goods',
    '_wcj_purchase_price' => 'Booster for WooCommerce',
    '_izettle_cost_price' => 'BjornTech PayPal POS integration for WooCommerce',
    '_ni_cost_goods'      => 'Ni Cost of Goods for WooCommerce',
    'cost_of_goods'       => 'Cost Of Goods For WooCommerce',
);

// Plugins with a table of their own: table without prefix => id column, cost column.
$TABLES = array(
    'atum_product_data' => array( 'product_id', 'purchase_price' ),   // ATUM Inventory Management for WooCommerce
);

global $wpdb;

$found = array();

$placeholders = implode( ', ', array_fill( 0, count( $KEYS ), '%s' ) );
$rows         = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT post_id, meta_key, meta_value
           FROM {$wpdb->postmeta}
          WHERE meta_key IN ( {$placeholders} )
            AND meta_value <> ''
            AND CAST( meta_value AS DECIMAL( 20, 6 ) ) > 0",
        array_keys( $KEYS )
    ),
    ARRAY_A
);
foreach ( (array) $rows as $row ) {
    $found[ (int) $row['post_id'] ][ $row['meta_key'] ] = $row['meta_value'];
}

foreach ( $TABLES as $table => $columns ) {
    list( $id_column, $cost_column ) = $columns;
    $name = $wpdb->prefix . $table;
    if ( $name !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $name ) ) ) ) {
        continue;                       // that plugin has never been installed here
    }
    $rows = $wpdb->get_results(
        "SELECT {$id_column} AS id, {$cost_column} AS cost
           FROM {$name}
          WHERE {$cost_column} IS NOT NULL
            AND CAST( {$cost_column} AS DECIMAL( 20, 6 ) ) > 0",
        ARRAY_A
    );
    foreach ( (array) $rows as $row ) {
        $found[ (int) $row['id'] ][ "{$table}.{$cost_column}" ] = $row['cost'];
    }
}

$moved = 0;
$owned = 0;
$parts = 0;
$held  = 0;
$clash = 0;
$gone  = 0;

foreach ( $found as $id => $values ) {
    $product = wc_get_product( $id );
    if ( ! $product ) {
        WP_CLI::warning( "No product with id {$id} - leftover data from something deleted." );
        $gone++;
        continue;
    }

    // A variation's cost can replace the parent's or be added on top of it.
    // That is a decision about what the number means, so the script leaves it alone.
    if ( $product->is_type( 'variation' ) ) {
        WP_CLI::log( sprintf( '[variation] %d %s: %s - left for you',
            $id, $product->get_name(), implode( ', ', $values ) ) );
        $parts++;
        continue;
    }

    $current = $product->get_cogs_value();
    if ( null !== $current && '' !== $current ) {
        $owned++;                       // never overwrite a cost already in the core field
        continue;
    }

    // Compared as numbers: "12.50" in one field and "12.5" in another are the same cost.
    $numbers = array();
    foreach ( $values as $value ) {
        $numbers[] = (string) round( (float) $value, 6 );
    }
    if ( count( array_unique( $numbers ) ) > 1 ) {
        $shown = array();
        foreach ( $values as $key => $value ) {
            $shown[] = "{$key}={$value}";
        }
        WP_CLI::warning( sprintf( 'Two costs disagree on %d %s: %s - skipped',
            $id, $product->get_name(), implode( ', ', $shown ) ) );
        $clash++;
        continue;
    }

    $cost = (float) reset( $values );

    // A variable product's cost is inherited by every variation without a cost of its
    // own in WooCommerce. If one of them has a DIFFERENT cost in the old plugin - or an
    // explicit zero in the same field - moving the parent would quietly give it the
    // parent's number instead. Such a parent is held back until its variations are set.
    if ( $product->is_type( 'variable' ) ) {
        $differ = array();
        foreach ( $product->get_children() as $child_id ) {
            $child = wc_get_product( $child_id );
            if ( ! $child || null !== $child->get_cogs_value() ) {
                continue;               // a cost of its own in WooCommerce is not inherited
            }
            $theirs = isset( $found[ $child_id ] ) ? $found[ $child_id ] : array();
            foreach ( array_keys( $values ) as $key ) {
                $zero = isset( $KEYS[ $key ] ) ? get_post_meta( $child_id, $key, true ) : '';
                if ( '' !== $zero && 0.0 === (float) $zero ) {
                    $theirs[ $key ] = '0';
                }
            }
            foreach ( $theirs as $value ) {
                if ( (string) round( (float) $value, 6 ) !== (string) round( $cost, 6 ) ) {
                    $differ[] = $child_id;
                    break;
                }
            }
        }
        if ( array() !== $differ ) {
            WP_CLI::warning( sprintf( 'Variable product %d %s: variations %s have a different cost in the old plugin, and moving %s would give them this one - held back, set those variations first',
                $id, $product->get_name(), implode( ', ', $differ ), $cost ) );
            $held++;
            continue;
        }
    }

    if ( $DRY_RUN ) {
        WP_CLI::log( sprintf( '[dry run] %d %s: -> %s (from %s)',
            $id, $product->get_name(), $cost, key( $values ) ) );
    } else {
        $product->set_cogs_value( $cost );
        $product->save();
        WP_CLI::log( sprintf( '%d %s = %s', $id, $product->get_name(), $cost ) );
    }
    $moved++;
}

WP_CLI::success( sprintf(
    '%s: %d | already had a cost: %d | variations left for you: %d | held back: %d | disagreements: %d | missing products: %d',
    $DRY_RUN ? 'To move' : 'Moved', $moved, $owned, $parts, $held, $clash, $gone
) );
wp eval-file migrate-costs.php

The script is here to be read, not downloaded in one click — deliberately. It writes to your database, so it should pass your eyes before it passes your products.

How to read the dry run

The last line is the whole report, and every number on it means something you may need to act on.

  • To move — products that will get a cost. This is the only number that changes anything.
  • Already had a cost — the core field is filled, so the script keeps its hands off. If this number covers your whole catalogue and nothing is left to move, you are done and the report was measuring something else; check that the feature is switched on.
  • Variations left for you — see below. The script never touches them.
  • Held back — a variable product whose own cost the script did not move, because some of its variations have a different cost in the old plugin. In WooCommerce a variation without a cost of its own uses its parent’s, so moving the parent would quietly give those variations the parent’s number. The warning names them; set them first, then run the script again.
  • Disagreements — the same product has different costs in two plugins’ fields. Nobody can tell which is current: none of them is dated. The script prints every one with the field names so you can settle them in the product editor, where you can see what you are choosing. Costs are compared as numbers, so 12.50 in one field and 12.5 in another are not a disagreement.
  • Missing products — meta left behind by deleted products. Harmless, and not worth cleaning up in the same pass as something that writes.

A zero in a foreign field is not moved, and neither is a negative amount. In WooCommerce a stored zero and a missing cost are the same thing, so moving a zero across changes nothing except the number of rows in your database; a negative cost is a data error to fix at the source, not a value to copy.

Variations are left alone on purpose

A variation inherits the parent product’s cost until it has one of its own, and when it does, one toggle decides whether that cost replaces the parent’s or is added on top. A shirt costing 24 with an embroidered variation at 3 comes to 27 in additive mode and to 3 in replace mode. That is a decision about what the number means, not what it is, and a script cannot make it from the data — the foreign field carries a value and no mode.

So: let the script fill parent products, then open the variations it listed and set them deliberately. A variable product’s own cost is moved only when none of its variations says otherwise in the old plugin — otherwise it is held back, as above. How costs work on variations is the short version.

ATUM has no purchase price field on a variable product itself — only on each of its variations — so an ATUM shop with variable products will see those rows in this list rather than in the moved column. That is the script being careful, not failing.

The trial worth not skipping

  1. A database backup. Not a product export — a backup. Saving a product touches more than the one field you came for.
  2. The dry run, read line by line. Especially the disagreements: they are the only part of this that needs a human.
  3. Then $DRY_RUN = false.
  4. Check in the shop, not in the terminal. Place one test order for a product that just got a cost and confirm the order recorded it. That is the only check covering the whole chain rather than the product record.

After the move

Keep the old plugin’s data. Nothing is gained by deleting it and it stays your only record of what the costs used to be. If you later switch the old plugin off, its values remain in the database; the script can be run again and will simply report that everything already has a cost.

Run it again after an import. Anything that writes into the old plugin’s field — a supplier sheet, a bulk edit, a stock sync — puts values back where the report cannot see them. Re-running the script is safe: it never overwrites a cost that is already in the core field.

If you are filling costs from a spreadsheet as well, bulk cost import is the other half of the same job.

What this will not fix

  • History. Orders placed before the move stay without a cost forever. Why there is no backfill.
  • Refunds. Issuing a refund makes WooCommerce recalculate the cost of every line on the original order from today’s product cost, so freshly moved values can rewrite the very history they describe. What happens then.
  • Genuine zeros. A product that really costs nothing is stored as no cost at all, so it stays on the missing-cost list and nothing here changes that.
  • Variable costs. Shipping, payment fees and returns to the supplier are not cost of goods. Gross profit is not net profit and we do not pretend otherwise.

If your shop behaves differently from what this page describes, it is worth writing in — these pages get corrected when they turn out to be wrong.